Merge branch 'feat/approval-phase1' into feat/approval-t26
This commit is contained in:
@@ -163,6 +163,13 @@ public final class ApprovalConstants {
|
|||||||
return CATEGORY_NAMES.getOrDefault(category, category);
|
return CATEGORY_NAMES.getOrDefault(category, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档后单据为只读终态:任何写动作统一返回该提示。
|
||||||
|
*/
|
||||||
|
public static String writeBlockedMessage(String status) {
|
||||||
|
return STATUS_ARCHIVED.equals(status) ? "单据已归档,不能修改" : "当前状态不可修改";
|
||||||
|
}
|
||||||
|
|
||||||
public static String departmentLeaderRoleCode(Integer organizationId) {
|
public static String departmentLeaderRoleCode(Integer organizationId) {
|
||||||
return organizationId == null ? null : "dept_leader_" + organizationId;
|
return organizationId == null ? null : "dept_leader_" + organizationId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.approval.controller;
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
|
||||||
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||||
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||||
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||||
@@ -40,6 +41,14 @@ public class ApprovalInstanceController extends BaseController {
|
|||||||
return success("保存成功", instanceService.saveCompletion(param, user));
|
return success("保存成功", instanceService.saveCompletion(param, user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
|
@ApiOperation("新建或保存内部协调单草稿")
|
||||||
|
@PostMapping("/instance/coordination")
|
||||||
|
public ApiResult<Long> saveCoordination(@RequestBody ApprovalCoordinationParam param) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
return success("保存成功", instanceService.saveCoordination(param, user));
|
||||||
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
@ApiOperation("提交或重提")
|
@ApiOperation("提交或重提")
|
||||||
@PostMapping("/instance/{id}/submit")
|
@PostMapping("/instance/{id}/submit")
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.gxwebsoft.approval.controller;
|
package com.gxwebsoft.approval.controller;
|
||||||
|
|
||||||
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
|
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||||
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||||
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||||
import com.gxwebsoft.approval.service.ApprovalTaskService;
|
import com.gxwebsoft.approval.service.ApprovalTaskService;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||||
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||||
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||||
|
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||||
import com.gxwebsoft.common.core.web.ApiResult;
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
import com.gxwebsoft.common.core.web.PageResult;
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
@@ -77,6 +80,25 @@ public class ApprovalTaskController extends BaseController {
|
|||||||
return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
|
return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||||
|
@ApiOperation("存档确认")
|
||||||
|
@PostMapping("/task/{id}/confirm")
|
||||||
|
public ApiResult<ApprovalInstanceVO> confirm(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.confirm(id, user);
|
||||||
|
return success(result.getWarning() == null ? "存档成功" : result.getWarning(),
|
||||||
|
instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:archive')")
|
||||||
|
@ApiOperation("归档")
|
||||||
|
@PostMapping("/task/{id}/archive")
|
||||||
|
public ApiResult<ApprovalInstanceVO> archive(@PathVariable("id") Long id) {
|
||||||
|
User user = getLoginUser();
|
||||||
|
ApprovalTaskService.Result result = taskService.archive(id, user);
|
||||||
|
return success("归档成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
@PreAuthorize("hasAuthority('approval:instance:submit')")
|
||||||
@ApiOperation("撤回")
|
@ApiOperation("撤回")
|
||||||
@PostMapping("/instance/{id}/withdraw")
|
@PostMapping("/instance/{id}/withdraw")
|
||||||
@@ -85,4 +107,13 @@ public class ApprovalTaskController extends BaseController {
|
|||||||
ApprovalTaskService.Result result = taskService.withdraw(id, user);
|
ApprovalTaskService.Result result = taskService.withdraw(id, user);
|
||||||
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
|
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||||
|
@OperationLog(value = "改派审批待办", module = "审批管理")
|
||||||
|
@ApiOperation("改派")
|
||||||
|
@PostMapping("/task/{id}/assign")
|
||||||
|
public ApiResult<ApprovalAssignVO> assign(@PathVariable("id") Long id,
|
||||||
|
@RequestBody(required = false) ApprovalTaskAssignParam param) {
|
||||||
|
return success("改派成功", taskService.assign(id, param, getLoginUser()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
i.reporter_id,
|
i.reporter_id,
|
||||||
i.reporter_name,
|
i.reporter_name,
|
||||||
i.report_org_name,
|
i.report_org_name,
|
||||||
i.accept_org_name,
|
COALESCE(i.accept_org_name, co.accept_org_name) AS accept_org_name,
|
||||||
i.archive_time,
|
i.archive_time,
|
||||||
i.current_node_key,
|
i.current_node_key,
|
||||||
i.definition_id,
|
i.definition_id,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。
|
||||||
|
*
|
||||||
|
* <p>报送部门与经办人由服务端按登录态带出,不在参数中接收。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalCoordinationParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("审批实例id(更新草稿时传入)")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ApiModelProperty("受理部门id,必须是启用组织")
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
@ApiModelProperty("标题")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@ApiModelProperty("内容")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@ApiModelProperty("备注")
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.gxwebsoft.approval.param;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办改派参数。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ApprovalTaskAssignParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@ApiModelProperty("改派对象用户id")
|
||||||
|
private Integer targetUserId;
|
||||||
|
|
||||||
|
@ApiModelProperty("改派原因;必填")
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -162,9 +162,11 @@ public class ApprovalAttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void checkEditable(ApprovalInstance instance, User user) {
|
private void checkEditable(ApprovalInstance instance, User user) {
|
||||||
if (user == null || !instance.getReporterId().equals(user.getUserId())
|
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
|
||||||
|| !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
throw new BusinessException("无权操作该单据");
|
||||||
throw new BusinessException("当前状态不可修改");
|
}
|
||||||
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
|
throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
|||||||
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
|
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
|
||||||
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||||
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
|
||||||
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
import com.gxwebsoft.approval.param.ApprovalMineParam;
|
||||||
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||||
@@ -99,6 +100,30 @@ public class ApprovalInstanceService {
|
|||||||
return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName));
|
return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建或更新内部协调单草稿,返回实例id。
|
||||||
|
*
|
||||||
|
* <p>报送部门与经办人取登录态;受理部门必须是启用组织,保存时即校验。</p>
|
||||||
|
*/
|
||||||
|
public Long saveCoordination(ApprovalCoordinationParam param, User user) {
|
||||||
|
validateCoordination(param);
|
||||||
|
ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId());
|
||||||
|
if (acceptOrganization == null) {
|
||||||
|
throw new BusinessException("受理部门不存在或已停用");
|
||||||
|
}
|
||||||
|
Integer organizationId = user.getOrganizationId();
|
||||||
|
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
|
||||||
|
String organizationName = organization == null ? null : organization.getOrganizationName();
|
||||||
|
|
||||||
|
if (param.getId() != null) {
|
||||||
|
return inTransaction(() ->
|
||||||
|
updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization));
|
||||||
|
}
|
||||||
|
ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
|
||||||
|
return inTransaction(() ->
|
||||||
|
createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交或重提,返回节点缺配提示;正常提交返回 null。
|
* 提交或重提,返回节点缺配提示;正常提交返回 null。
|
||||||
*/
|
*/
|
||||||
@@ -113,7 +138,14 @@ public class ApprovalInstanceService {
|
|||||||
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
throw new BusinessException(submitBlockedMessage(instance.getStatus()));
|
throw new BusinessException(submitBlockedMessage(instance.getStatus()));
|
||||||
}
|
}
|
||||||
if (completionForm(instanceId) == null) {
|
boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType());
|
||||||
|
ApprovalCoordinationForm coordinationForm = null;
|
||||||
|
if (coordination) {
|
||||||
|
coordinationForm = coordinationForm(instanceId);
|
||||||
|
if (coordinationForm == null) {
|
||||||
|
throw new BusinessException("单据内容不存在");
|
||||||
|
}
|
||||||
|
} else if (completionForm(instanceId) == null) {
|
||||||
throw new BusinessException("单据内容不存在");
|
throw new BusinessException("单据内容不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +162,10 @@ public class ApprovalInstanceService {
|
|||||||
if (leaders.isEmpty()) {
|
if (leaders.isEmpty()) {
|
||||||
throw new BusinessException("报送部门负责人角色缺配");
|
throw new BusinessException("报送部门负责人角色缺配");
|
||||||
}
|
}
|
||||||
|
ApprovalOrganizationVO acceptOrganization = null;
|
||||||
|
if (coordination) {
|
||||||
|
acceptOrganization = preflightAcceptOrganization(coordinationForm);
|
||||||
|
}
|
||||||
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
|
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
|
||||||
throw new BusinessException("收文员角色缺配");
|
throw new BusinessException("收文员角色缺配");
|
||||||
}
|
}
|
||||||
@@ -141,11 +177,30 @@ public class ApprovalInstanceService {
|
|||||||
.map(this::toApprover)
|
.map(this::toApprover)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return inTransaction(() -> doSubmit(instanceId, user, organization, approvers));
|
ApprovalOrganizationVO acceptOrg = acceptOrganization;
|
||||||
|
return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协调单提交预检:受理部门负责人角色、受理部门停用。
|
||||||
|
*/
|
||||||
|
private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) {
|
||||||
|
Integer acceptOrgId = form.getAcceptOrgId();
|
||||||
|
if (acceptOrgId == null || acceptOrgId <= 0) {
|
||||||
|
throw new BusinessException("请选择受理部门");
|
||||||
|
}
|
||||||
|
if (directoryService.listRoleMembers(ApprovalConstants.departmentLeaderRoleCode(acceptOrgId)).isEmpty()) {
|
||||||
|
throw new BusinessException("受理部门负责人角色缺配");
|
||||||
|
}
|
||||||
|
ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId);
|
||||||
|
if (acceptOrganization == null) {
|
||||||
|
throw new BusinessException("受理部门已停用");
|
||||||
|
}
|
||||||
|
return acceptOrganization;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
|
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
|
||||||
List<ApprovalApprover> approvers) {
|
ApprovalOrganizationVO acceptOrganization, List<ApprovalApprover> approvers) {
|
||||||
ApprovalInstance locked = engineService.lockInstance(instanceId);
|
ApprovalInstance locked = engineService.lockInstance(instanceId);
|
||||||
if (!locked.getReporterId().equals(user.getUserId())
|
if (!locked.getReporterId().equals(user.getUserId())
|
||||||
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
|
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
|
||||||
@@ -154,8 +209,15 @@ public class ApprovalInstanceService {
|
|||||||
boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
|
boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
|
||||||
locked.setReportOrgId(organization.getOrganizationId());
|
locked.setReportOrgId(organization.getOrganizationId());
|
||||||
locked.setReportOrgName(organization.getOrganizationName());
|
locked.setReportOrgName(organization.getOrganizationName());
|
||||||
|
if (acceptOrganization != null) {
|
||||||
|
locked.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
locked.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
}
|
||||||
if (firstSubmit) {
|
if (firstSubmit) {
|
||||||
locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION));
|
boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType());
|
||||||
|
locked.setBizNo(noService.nextNo(coordination
|
||||||
|
? ApprovalConstants.NO_PREFIX_COORDINATION
|
||||||
|
: ApprovalConstants.NO_PREFIX_COMPLETION));
|
||||||
}
|
}
|
||||||
locked.setStatus(ApprovalConstants.STATUS_PENDING);
|
locked.setStatus(ApprovalConstants.STATUS_PENDING);
|
||||||
locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1);
|
locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1);
|
||||||
@@ -305,7 +367,7 @@ public class ApprovalInstanceService {
|
|||||||
throw new BusinessException("无权操作该单据");
|
throw new BusinessException("无权操作该单据");
|
||||||
}
|
}
|
||||||
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
throw new BusinessException("当前状态不可修改");
|
throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
|
||||||
}
|
}
|
||||||
ApprovalCompletionForm form = completionForm(instance.getId());
|
ApprovalCompletionForm form = completionForm(instance.getId());
|
||||||
if (form == null) {
|
if (form == null) {
|
||||||
@@ -323,6 +385,79 @@ public class ApprovalInstanceService {
|
|||||||
return instance.getId();
|
return instance.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long createCoordinationDraft(ApprovalCoordinationParam param, User user,
|
||||||
|
ApprovalFlowDefinition definition,
|
||||||
|
Integer organizationId, String organizationName,
|
||||||
|
ApprovalOrganizationVO acceptOrganization) {
|
||||||
|
ApprovalInstance instance = new ApprovalInstance();
|
||||||
|
instance.setFlowCode(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
|
||||||
|
instance.setDefinitionId(definition.getId());
|
||||||
|
instance.setDefinitionVersion(definition.getVersion());
|
||||||
|
instance.setDocType(ApprovalConstants.DOC_COORDINATION);
|
||||||
|
instance.setBizId(0L);
|
||||||
|
instance.setStatus(ApprovalConstants.STATUS_DRAFT);
|
||||||
|
instance.setRound(0);
|
||||||
|
instance.setReporterId(user.getUserId());
|
||||||
|
instance.setReporterName(displayName(user));
|
||||||
|
instance.setReportOrgId(organizationId);
|
||||||
|
instance.setReportOrgName(organizationName);
|
||||||
|
instanceMapper.insert(instance);
|
||||||
|
|
||||||
|
ApprovalCoordinationForm form = new ApprovalCoordinationForm();
|
||||||
|
form.setInstanceId(instance.getId());
|
||||||
|
form.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
form.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
form.setTitle(param.getTitle().trim());
|
||||||
|
form.setContent(param.getContent().trim());
|
||||||
|
form.setRemark(normalizeRemark(param.getRemark()));
|
||||||
|
coordinationFormMapper.insert(form);
|
||||||
|
|
||||||
|
instance.setBizId(form.getId());
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
return instance.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long updateCoordinationDraft(ApprovalCoordinationParam param, User user,
|
||||||
|
Integer organizationId, String organizationName,
|
||||||
|
ApprovalOrganizationVO acceptOrganization) {
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(param.getId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType())) {
|
||||||
|
throw new BusinessException("单据类型不匹配");
|
||||||
|
}
|
||||||
|
if (!instance.getReporterId().equals(user.getUserId())) {
|
||||||
|
throw new BusinessException("无权操作该单据");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
|
||||||
|
throw new BusinessException("当前状态不可修改");
|
||||||
|
}
|
||||||
|
ApprovalCoordinationForm form = coordinationForm(instance.getId());
|
||||||
|
if (form == null) {
|
||||||
|
throw new BusinessException("单据内容不存在");
|
||||||
|
}
|
||||||
|
form.setAcceptOrgId(acceptOrganization.getOrganizationId());
|
||||||
|
form.setAcceptOrgName(acceptOrganization.getOrganizationName());
|
||||||
|
form.setTitle(param.getTitle().trim());
|
||||||
|
form.setContent(param.getContent().trim());
|
||||||
|
form.setRemark(normalizeRemark(param.getRemark()));
|
||||||
|
coordinationFormMapper.updateById(form);
|
||||||
|
|
||||||
|
instance.setReportOrgId(organizationId);
|
||||||
|
instance.setReportOrgName(organizationName);
|
||||||
|
instanceMapper.updateById(instance);
|
||||||
|
return instance.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRemark(String remark) {
|
||||||
|
if (remark == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = remark.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
private ApprovalInstanceVO baseVO(ApprovalInstance instance) {
|
private ApprovalInstanceVO baseVO(ApprovalInstance instance) {
|
||||||
ApprovalInstanceVO vo = new ApprovalInstanceVO();
|
ApprovalInstanceVO vo = new ApprovalInstanceVO();
|
||||||
vo.setId(instance.getId());
|
vo.setId(instance.getId());
|
||||||
@@ -502,6 +637,33 @@ public class ApprovalInstanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateCoordination(ApprovalCoordinationParam param) {
|
||||||
|
if (param == null) {
|
||||||
|
throw new BusinessException("参数不能为空");
|
||||||
|
}
|
||||||
|
if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) {
|
||||||
|
throw new BusinessException("请选择受理部门");
|
||||||
|
}
|
||||||
|
String title = param.getTitle() == null ? "" : param.getTitle().trim();
|
||||||
|
if (title.isEmpty()) {
|
||||||
|
throw new BusinessException("标题不能为空");
|
||||||
|
}
|
||||||
|
if (title.length() > 100) {
|
||||||
|
throw new BusinessException("标题不能超过100字");
|
||||||
|
}
|
||||||
|
String content = param.getContent() == null ? "" : param.getContent().trim();
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
throw new BusinessException("内容不能为空");
|
||||||
|
}
|
||||||
|
if (content.length() > 2000) {
|
||||||
|
throw new BusinessException("内容不能超过2000字");
|
||||||
|
}
|
||||||
|
String remark = normalizeRemark(param.getRemark());
|
||||||
|
if (remark != null && remark.length() > 500) {
|
||||||
|
throw new BusinessException("备注不能超过500字");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String submitBlockedMessage(String status) {
|
private String submitBlockedMessage(String status) {
|
||||||
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
|
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
|
||||||
return "单据正在审批中,不能重复提交";
|
return "单据正在审批中,不能重复提交";
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import com.gxwebsoft.approval.entity.ApprovalInstance;
|
|||||||
import com.gxwebsoft.approval.entity.ApprovalTask;
|
import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||||
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||||
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||||
|
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||||
|
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
@@ -75,6 +77,45 @@ public class ApprovalTaskService {
|
|||||||
return new Result(instance.getId(), warning);
|
return new Result(instance.getId(), warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成,
|
||||||
|
* 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。
|
||||||
|
*/
|
||||||
|
public Result confirm(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持存档确认");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureNotArchived(instance);
|
||||||
|
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
|
||||||
|
List<ApprovalApprover> approvers =
|
||||||
|
next == null ? Collections.emptyList() : resolveApprovers(instance, next);
|
||||||
|
|
||||||
|
String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers));
|
||||||
|
return new Result(instance.getId(), warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档:ARCHIVE 任务或签,处理后取消同节点其余待办,实例进入只读终态。
|
||||||
|
*/
|
||||||
|
public Result archive(Long taskId, User user) {
|
||||||
|
ApprovalTask task = requireTask(taskId, user);
|
||||||
|
if (!ApprovalConstants.NODE_ARCHIVE.equals(task.getNodeType())) {
|
||||||
|
throw new BusinessException("当前任务不支持归档");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
ensureNotArchived(instance);
|
||||||
|
inTransaction(() -> doArchive(taskId, task, user));
|
||||||
|
return new Result(instance.getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
|
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +142,9 @@ public class ApprovalTaskService {
|
|||||||
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
|
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
|
||||||
throw new BusinessException("无权操作该单据");
|
throw new BusinessException("无权操作该单据");
|
||||||
}
|
}
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据已归档,不能修改");
|
||||||
|
}
|
||||||
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
throw new BusinessException("当前状态不可撤回");
|
throw new BusinessException("当前状态不可撤回");
|
||||||
}
|
}
|
||||||
@@ -109,6 +153,28 @@ public class ApprovalTaskService {
|
|||||||
return new Result(instanceId, null);
|
return new Result(instanceId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。
|
||||||
|
*
|
||||||
|
* <p>改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。</p>
|
||||||
|
*/
|
||||||
|
public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) {
|
||||||
|
if (param == null || param.getTargetUserId() == null) {
|
||||||
|
throw new BusinessException("请选择改派对象");
|
||||||
|
}
|
||||||
|
String reason = requireAssignReason(param.getReason());
|
||||||
|
ApprovalTask task = requireAssignableTask(taskId);
|
||||||
|
// 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户)
|
||||||
|
List<ApprovalUserVO> targets =
|
||||||
|
directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId()));
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
throw new BusinessException("改派对象不存在或已停用");
|
||||||
|
}
|
||||||
|
ApprovalUserVO target = targets.get(0);
|
||||||
|
String targetName = displayName(target);
|
||||||
|
return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
|
||||||
|
}
|
||||||
|
|
||||||
public Integer todoCount(User user) {
|
public Integer todoCount(User user) {
|
||||||
return taskMapper.countPendingInstances(user.getUserId());
|
return taskMapper.countPendingInstances(user.getUserId());
|
||||||
}
|
}
|
||||||
@@ -131,6 +197,67 @@ public class ApprovalTaskService {
|
|||||||
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
|
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String doConfirm(Long taskId, ApprovalTask task, User user,
|
||||||
|
ApprovalNode next, List<ApprovalApprover> approvers) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureNotArchived(locked);
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_CONFIRM, null);
|
||||||
|
Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
|
.eq(ApprovalTask::getInstanceId, locked.getId())
|
||||||
|
.eq(ApprovalTask::getRound, locked.getRound())
|
||||||
|
.eq(ApprovalTask::getNodeKey, task.getNodeKey())
|
||||||
|
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING));
|
||||||
|
if (remaining != null && remaining > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (next == null) {
|
||||||
|
return "当前节点缺少审批人,请联系管理员补配置";
|
||||||
|
}
|
||||||
|
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
|
||||||
|
if (created == 0) {
|
||||||
|
return "当前节点缺少审批人,请联系管理员补配置";
|
||||||
|
}
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||||
|
.set("status", ApprovalConstants.STATUS_APPROVED)
|
||||||
|
.set("approve_time", LocalDateTime.now()));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doArchive(Long taskId, ApprovalTask task, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureNotArchived(locked);
|
||||||
|
if (!ApprovalConstants.STATUS_APPROVED.equals(locked.getStatus())) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
ensureTaskCurrent(task, locked);
|
||||||
|
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_ARCHIVE, null);
|
||||||
|
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||||
|
.eq("id", locked.getId())
|
||||||
|
.eq("status", ApprovalConstants.STATUS_APPROVED)
|
||||||
|
.set("status", ApprovalConstants.STATUS_ARCHIVED)
|
||||||
|
.set("archive_time", LocalDateTime.now())
|
||||||
|
.set("archivist_id", user.getUserId())
|
||||||
|
.set("archivist_name", displayName(user)));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
|
private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
|
||||||
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
ensureTaskHandleable(task, locked);
|
ensureTaskHandleable(task, locked);
|
||||||
@@ -171,6 +298,58 @@ public class ApprovalTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。
|
||||||
|
*/
|
||||||
|
private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId,
|
||||||
|
String targetName, String reason, User user) {
|
||||||
|
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||||
|
ensureTaskHandleable(task, locked);
|
||||||
|
int rows = taskMapper.update(null, new UpdateWrapper<ApprovalTask>()
|
||||||
|
.eq("id", taskId)
|
||||||
|
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||||
|
.set("assignee_id", targetUserId)
|
||||||
|
.set("assignee_name", targetName));
|
||||||
|
if (rows == 0) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||||
|
ApprovalConstants.ACTION_ASSIGN, reason);
|
||||||
|
ApprovalAssignVO vo = new ApprovalAssignVO();
|
||||||
|
vo.setInstanceId(locked.getId());
|
||||||
|
vo.setTaskId(taskId);
|
||||||
|
vo.setOriginalAssigneeId(task.getAssigneeId());
|
||||||
|
vo.setOriginalAssigneeName(task.getAssigneeName());
|
||||||
|
vo.setAssigneeId(targetUserId);
|
||||||
|
vo.setAssigneeName(targetName);
|
||||||
|
vo.setReason(reason);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派前的基本校验:任务存在、待处理且单据仍在审批中。
|
||||||
|
*/
|
||||||
|
private ApprovalTask requireAssignableTask(Long taskId) {
|
||||||
|
if (taskId == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
ApprovalTask task = taskMapper.selectById(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) {
|
||||||
|
throw new BusinessException("任务已处理");
|
||||||
|
}
|
||||||
|
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||||
|
if (instance == null) {
|
||||||
|
throw new BusinessException("单据不存在");
|
||||||
|
}
|
||||||
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据不在审批中,无法改派");
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
|
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
|
||||||
*/
|
*/
|
||||||
@@ -178,12 +357,22 @@ public class ApprovalTaskService {
|
|||||||
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||||
throw new BusinessException("单据状态已变化,请刷新后重试");
|
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||||
}
|
}
|
||||||
|
ensureTaskCurrent(task, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureTaskCurrent(ApprovalTask task, ApprovalInstance instance) {
|
||||||
if (!task.getRound().equals(instance.getRound())
|
if (!task.getRound().equals(instance.getRound())
|
||||||
|| !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
|
|| !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
|
||||||
throw new BusinessException("任务已处理");
|
throw new BusinessException("任务已处理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureNotArchived(ApprovalInstance instance) {
|
||||||
|
if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
|
||||||
|
throw new BusinessException("单据已归档,不能修改");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ensureNoHandledTask(ApprovalInstance instance) {
|
private void ensureNoHandledTask(ApprovalInstance instance) {
|
||||||
Integer handled = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
Integer handled = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
|
||||||
.eq(ApprovalTask::getInstanceId, instance.getId())
|
.eq(ApprovalTask::getInstanceId, instance.getId())
|
||||||
@@ -288,6 +477,16 @@ public class ApprovalTaskService {
|
|||||||
return user.getUsername();
|
return user.getUsername();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String displayName(User user) {
|
||||||
|
if (StrUtil.isNotBlank(user.getRealName())) {
|
||||||
|
return user.getRealName();
|
||||||
|
}
|
||||||
|
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||||
|
return user.getNickname();
|
||||||
|
}
|
||||||
|
return user.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeOpinion(String opinion) {
|
private String normalizeOpinion(String opinion) {
|
||||||
if (opinion == null) {
|
if (opinion == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -310,6 +509,14 @@ public class ApprovalTaskService {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String requireAssignReason(String reason) {
|
||||||
|
String normalized = normalizeOpinion(reason);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new BusinessException("请填写改派原因");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private <T> T inTransaction(Supplier<T> action) {
|
private <T> T inTransaction(Supplier<T> action) {
|
||||||
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.gxwebsoft.approval.vo;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "改派结果")
|
||||||
|
public class ApprovalAssignVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long instanceId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private Integer originalAssigneeId;
|
||||||
|
|
||||||
|
private String originalAssigneeName;
|
||||||
|
|
||||||
|
private Integer assigneeId;
|
||||||
|
|
||||||
|
private String assigneeName;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.support.ApprovalTestData;
|
||||||
|
import com.gxwebsoft.support.TestAuthentication;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档确认与归档闭环集成测试:全员存档、部分存档、归档或签、归档后只读、
|
||||||
|
* 归档缺配阻断、收文员与 superAdmin 全租户可见。
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalConfirmArchiveIT {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立租户:收文员解析按 (tenant_id, role_code) 全局匹配,使用专属租户可避免
|
||||||
|
* 与并行执行的其它审批测试类互相污染角色成员。
|
||||||
|
*/
|
||||||
|
private static final int TENANT = 10024;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
private static final String ARCHIVE = "approval:instance:archive";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser leader;
|
||||||
|
private ApprovalTestData.TestUser archivist;
|
||||||
|
private ApprovalTestData.TestUser secondArchivist;
|
||||||
|
private ApprovalTestData.TestUser superAdmin;
|
||||||
|
private ApprovalTestData.TestUser outsider;
|
||||||
|
private Integer organizationId;
|
||||||
|
private Integer archivistRoleId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
organizationId = data.createOrganization("归档部门" + data.uniqueSuffix(), 136, TENANT);
|
||||||
|
reporter = data.createUser("归档报送人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole("dept_leader_" + organizationId, "部门负责人", TENANT);
|
||||||
|
leader = data.createUser("归档负责人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(leader.userId, leaderRole.roleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
|
||||||
|
archivistRoleId = archivistRole.roleId;
|
||||||
|
archivist = data.createUser("归档收文员" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
secondArchivist = data.createUser("归档收文员B" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(archivist.userId, archivistRoleId, TENANT);
|
||||||
|
data.bindTenant(secondArchivist.userId, archivistRoleId, TENANT);
|
||||||
|
|
||||||
|
ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT);
|
||||||
|
superAdmin = data.createUser("归档超管" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT);
|
||||||
|
|
||||||
|
outsider = data.createUser("归档路人" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmsOnlyAfterAllConfirmTasksDone() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("全员存档");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
long reporterTask = pendingTaskId(instanceId, reporter.userId);
|
||||||
|
|
||||||
|
// 存档节点缺配前先验证:CONFIRM 任务不能驳回,也不需要意见
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/reject"),
|
||||||
|
reporter, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"不同意\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持驳回")));
|
||||||
|
|
||||||
|
// 追加一名并存档人(协调单场景:报送人 + 派单对象)
|
||||||
|
ApprovalTestData.TestUser secondConfirmer = data.createUser("归档存档人B" + data.uniqueSuffix(), organizationId);
|
||||||
|
long secondTask = insertConfirmTask(instanceId, secondConfirmer);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), secondConfirmer, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.confirms.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].statusName").value("待确认"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
|
||||||
|
|
||||||
|
// 部分存档:仍等待其他人
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"), reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].status").value("DONE"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].statusName").value("已确认"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[0].handleTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"));
|
||||||
|
assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?",
|
||||||
|
Object.class, instanceId)).isNull();
|
||||||
|
|
||||||
|
// 全员存档完成 → APPROVED,并为全部收文员生成或签归档任务
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/confirm"),
|
||||||
|
secondConfirmer, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
|
||||||
|
.andExpect(jsonPath("$.data.approveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("归档"))
|
||||||
|
.andExpect(jsonPath("$.data.confirms[1].status").value("DONE"));
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'ARCHIVE' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
|
||||||
|
instanceId)).isEqualTo(2);
|
||||||
|
// 存档记录不带意见
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void archivesOnceAndCancelsOtherArchiveTasks() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档或签");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
|
||||||
|
long firstArchive = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
long secondArchive = pendingTaskId(instanceId, secondArchivist.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + firstArchive + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||||
|
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.archivistId").value(archivist.userId))
|
||||||
|
.andExpect(jsonPath("$.data.archivistName").value(archivist.username));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondArchive))
|
||||||
|
.isEqualTo("CANCELLED");
|
||||||
|
assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, firstArchive))
|
||||||
|
.isNotNull();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondArchive + "/archive"),
|
||||||
|
secondArchivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已归档")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEveryWriteAfterArchive() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档只读");
|
||||||
|
long attachmentId = data.queryMaster("SELECT id FROM approval_attachment WHERE instance_id = ? LIMIT 1",
|
||||||
|
Long.class, instanceId);
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
long confirmTask = pendingTaskId(instanceId, reporter.userId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canWithdraw").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canConfirm").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canArchive").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||||
|
|
||||||
|
String archived = "单据已归档,不能修改";
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"id\":" + instanceId + ",\"name\":\"改归档单据\","
|
||||||
|
+ "\"implementOrg\":\"广西体育中心实施部\",\"fee\":1.00,"
|
||||||
|
+ "\"serviceContent\":\"尝试修改已归档单据\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(archived));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("ARCHIVED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksWhenArchiveNodeHasNoApprover() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("归档缺配");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
|
||||||
|
// 提交后收文员角色被清空,存档完成时归档节点缺配
|
||||||
|
data.server().update("DELETE FROM sys_user_role WHERE role_id = ?", archivistRoleId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||||
|
reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("缺少审批人")))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("归档"))
|
||||||
|
.andExpect(jsonPath("$.data.approveTime").doesNotExist());
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("archive");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND node_type = 'ARCHIVE'", instanceId)).isZero();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
|
||||||
|
instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void letsArchivistAndSuperAdminViewAllTenantInstances() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("全租户可见");
|
||||||
|
approveByLeader(instanceId);
|
||||||
|
confirmReporterTask(instanceId);
|
||||||
|
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
// 收文员与 superAdmin 非参与人也可见完整详情(含归档人/时间与附件)
|
||||||
|
ApprovalTestData.TestUser otherArchivist =
|
||||||
|
data.createUser("归档外聘收文员" + data.uniqueSuffix(), organizationId, TENANT);
|
||||||
|
data.bindTenant(otherArchivist.userId, archivistRoleId, TENANT);
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), otherArchivist, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||||
|
.andExpect(jsonPath("$.data.archivistName").value(archivist.username))
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.records.length()").isNotEmpty());
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), superAdmin, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value((int) instanceId));
|
||||||
|
|
||||||
|
// 普通参与人之外的用户仍被拒绝
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
// 参与人(报送人)可见
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private long createAndSubmit(String name) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name)
|
||||||
|
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
|
||||||
|
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
|
||||||
|
reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void approveByLeader(long instanceId) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + "/approve"),
|
||||||
|
leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意办理\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirmReporterTask(long instanceId) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||||
|
reporter, organizationId, APPROVE))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("APPROVED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long insertConfirmTask(long instanceId, ApprovalTestData.TestUser assignee) {
|
||||||
|
Integer round = data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
|
||||||
|
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||||
|
+ "task_type, assignee_id, assignee_name, status) VALUES (?, ?, 'confirm', 'CONFIRM', ?, 'CONFIRM', ?, ?, 'PENDING')",
|
||||||
|
TENANT, instanceId, round, assignee.userId, assignee.realName);
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? AND assignee_id = ? "
|
||||||
|
+ "AND status = 'PENDING'", Long.class, instanceId, assignee.userId);
|
||||||
|
assertThat(taskId).as("并存档任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.support.ApprovalTestData;
|
||||||
|
import com.gxwebsoft.support.TestAuthentication;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部协调单:草稿、字段校验、提交预检、部门快照与双节点审批的集成测试。
|
||||||
|
*
|
||||||
|
* <p>不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
|
||||||
|
* 数据由 {@link ApprovalTestData#cleanup()} 清理。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalCoordinationIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private Integer reportOrgId;
|
||||||
|
private Integer acceptOrgId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
reportOrgId = data.createOrganization("协调报送部门" + data.uniqueSuffix(), 136);
|
||||||
|
acceptOrgId = data.createOrganization("协调受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
reporter = data.createUser("协调报送人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsDraftWithServerSideReportFields() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "设备借用协调",
|
||||||
|
"借用体育馆音响设备一天", "活动结束后归还");
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("coordination");
|
||||||
|
assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo("coordination_internal");
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isEqualTo(reporter.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isEqualTo(reporter.username);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isEqualTo(reportOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_name FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||||
|
.isNotBlank();
|
||||||
|
// 受理部门在提交时才写入实例快照
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(acceptOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("设备借用协调");
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("活动结束后归还");
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.docType").value("coordination"))
|
||||||
|
.andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("未提交"))
|
||||||
|
.andExpect(jsonPath("$.data.title").value("设备借用协调"))
|
||||||
|
.andExpect(jsonPath("$.data.content").value("借用体育馆音响设备一天"))
|
||||||
|
.andExpect(jsonPath("$.data.remark").value("活动结束后归还"))
|
||||||
|
.andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").doesNotExist());
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].title").value("设备借用协调"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
|
||||||
|
|
||||||
|
// 流程定义按租户幂等,节点顺序固定;一期不产生外部协调流程
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", TENANT)).isEqualTo(1);
|
||||||
|
String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", String.class, TENANT);
|
||||||
|
assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch")
|
||||||
|
.contains("confirm").contains("archive");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
|
||||||
|
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
|
||||||
|
|
||||||
|
// 报送部门与经办人只取登录态,客户端伪造字段被忽略
|
||||||
|
ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\","
|
||||||
|
+ "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}";
|
||||||
|
String forgedResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||||
|
reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(forged))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long forgedId = objectMapper.readTree(forgedResponse).path("data").asLong();
|
||||||
|
data.trackInstance(forgedId);
|
||||||
|
assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, forgedId)).isEqualTo(reporter.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, forgedId)).isEqualTo(reportOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatesDraftOnlyForOwnerInEditableState() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "原标题", "原内容", null);
|
||||||
|
Integer otherAcceptOrg = data.createOrganization("备用受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(otherAcceptOrg, "新标题", "新内容", "补充说明", instanceId)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data").value((int) instanceId));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("新标题");
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(otherAcceptOrg);
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("补充说明");
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("协调路人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), outsider, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(otherAcceptOrg, "越权标题", "内容", null, instanceId)))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
|
||||||
|
|
||||||
|
// 提交后不可再修改
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(otherAcceptOrg, 1);
|
||||||
|
grantArchivist();
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(acceptOrgId, "提交后改标题", "内容", null, instanceId)))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesCoordinationFields() throws Exception {
|
||||||
|
expectSaveFailure(body(null, "标题", "内容", null, null), "请选择受理部门");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "", "内容", null, null), "标题不能为空");
|
||||||
|
expectSaveFailure(body(acceptOrgId, repeat("标", 101), "内容", null, null), "标题不能超过100字");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", "", null, null), "内容不能为空");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", repeat("内", 2001), null, null), "内容不能超过2000字");
|
||||||
|
expectSaveFailure(body(acceptOrgId, "标题", "内容", repeat("备", 501), null), "备注不能超过500字");
|
||||||
|
|
||||||
|
Integer disabledOrg = data.createOrganization("停用受理部门" + data.uniqueSuffix(), 136);
|
||||||
|
data.disableOrganization(disabledOrg);
|
||||||
|
expectSaveFailure(body(disabledOrg, "标题", "内容", null, null), "受理部门不存在或已停用");
|
||||||
|
|
||||||
|
// 备注为可选字段,留空保存成功
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "无备注", "内容", " ");
|
||||||
|
assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
|
||||||
|
String.class, instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenReportLeaderRoleMissing() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺报送负责人", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenAcceptLeaderRoleMissing() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺受理负责人", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门负责人角色缺配")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSubmitWhenAcceptOrganizationDisabled() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "受理部门停用", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
data.disableOrganization(acceptOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门已停用")));
|
||||||
|
|
||||||
|
assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
|
||||||
|
assertThat(bizNo(instanceId)).isNull();
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitsWithXtBizNoAndSnapshotsBothOrganizations() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "提交协调单", "协调内容", "备注内容");
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("审批中"))
|
||||||
|
.andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
|
||||||
|
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(false));
|
||||||
|
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
assertThat(bizNo).matches("XT\\d{8}-\\d{4}");
|
||||||
|
assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId))
|
||||||
|
.isEqualTo(1);
|
||||||
|
assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId))
|
||||||
|
.isNotNull();
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("reportLeader");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("APPROVE");
|
||||||
|
assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(reportOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
|
||||||
|
Integer.class, instanceId)).isEqualTo(acceptOrgId);
|
||||||
|
assertThat(data.queryMaster("SELECT accept_org_name FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId))
|
||||||
|
.isEqualTo(data.server().queryForObject(
|
||||||
|
"SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||||
|
String.class, acceptOrgId));
|
||||||
|
|
||||||
|
assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvesReportThenAcceptLeaderAndReachesDispatch() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
ApprovalTestData.TestUser acceptLeader = grantDepartmentLeader(acceptOrgId, 1).get(0);
|
||||||
|
grantArchivist();
|
||||||
|
ApprovalTestData.TestUser acceptMember = data.createUser("受理部门成员" + data.uniqueSuffix(), acceptOrgId);
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "双节点审批", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), reportLeader, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
long reportTask = pendingTaskId(instanceId, reportLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + reportTask + "/approve"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"同意协调\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"))
|
||||||
|
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("acceptLeader");
|
||||||
|
assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), acceptLeader, acceptOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||||
|
|
||||||
|
long acceptTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + acceptTask + "/approve"),
|
||||||
|
acceptLeader, acceptOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"opinion\":\"可以协调\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("派单"));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("dispatch");
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("DISPATCH");
|
||||||
|
assertThat(pendingTaskIds(instanceId)).contains(acceptMember.userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE'", instanceId)).isEqualTo(2);
|
||||||
|
assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask))
|
||||||
|
.isEqualTo("同意协调");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsSamePersonToHandleBothApprovalNodes() throws Exception {
|
||||||
|
ApprovalTestData.TestUser both = data.createUser("双部门负责人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
ApprovalTestData.TestRole reportRole = data.createRole("dept_leader_" + reportOrgId, "部门负责人");
|
||||||
|
ApprovalTestData.TestRole acceptRole = data.createRole("dept_leader_" + acceptOrgId, "部门负责人");
|
||||||
|
data.bind(both.userId, reportRole.roleId);
|
||||||
|
data.bind(both.userId, acceptRole.roleId);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "同人连做", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
|
||||||
|
long firstTask = pendingTaskId(instanceId, both.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), both, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"));
|
||||||
|
|
||||||
|
long secondTask = pendingTaskId(instanceId, both.userId);
|
||||||
|
assertThat(secondTask).isNotEqualTo(firstTask);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), both, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("派单"));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||||
|
String.class, instanceId)).isEqualTo("dispatch");
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'APPROVE' AND operator_id = ?", instanceId, both.userId)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsThenResubmitsKeepingBizNo() throws Exception {
|
||||||
|
ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "驳回重提协调", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
|
||||||
|
long taskId = pendingTaskId(instanceId, reportLeader.userId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\" \"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
|
||||||
|
reportLeader, reportOrgId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"协调范围不清\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("REJECTED"))
|
||||||
|
.andExpect(jsonPath("$.data.statusName").value("已驳回"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
assertThat(pendingTaskId(instanceId, reportLeader.userId)).isPositive();
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'REJECT' AND opinion = '协调范围不清'", instanceId)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawsAndResubmitsBeforeAnyApproval() throws Exception {
|
||||||
|
grantDepartmentLeader(reportOrgId, 1);
|
||||||
|
grantDepartmentLeader(acceptOrgId, 1);
|
||||||
|
grantArchivist();
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "撤回协调", "内容", null);
|
||||||
|
uploadAttachment(instanceId, reporter, reportOrgId);
|
||||||
|
submitCoordination(instanceId, reporter, reportOrgId);
|
||||||
|
String bizNo = bizNo(instanceId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo));
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, reportOrgId, SUBMIT))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.data.round").value(3))
|
||||||
|
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hidesDraftFromOutsider() throws Exception {
|
||||||
|
long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "草稿保密", "内容", null);
|
||||||
|
ApprovalTestData.TestUser outsider = data.createUser("协调外人" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, reportOrgId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private long saveCoordination(ApprovalTestData.TestUser user, Integer reportOrg, Integer acceptOrg,
|
||||||
|
String title, String content, String remark) throws Exception {
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||||
|
user, reportOrg, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body(acceptOrg, title, content, remark, null)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode node = objectMapper.readTree(response);
|
||||||
|
long id = node.path("data").asLong();
|
||||||
|
data.trackInstance(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void expectSaveFailure(String body, String expectedMessage) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void submitCoordination(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ApprovalTestData.TestUser> grantDepartmentLeader(int orgId, int memberCount) {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人");
|
||||||
|
List<ApprovalTestData.TestUser> members = new ArrayList<>();
|
||||||
|
for (int i = 0; i < memberCount; i++) {
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("协调负责人" + data.uniqueSuffix(), orgId);
|
||||||
|
data.bind(member.userId, role.roleId);
|
||||||
|
members.add(member);
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void grantArchivist() {
|
||||||
|
ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员");
|
||||||
|
ApprovalTestData.TestUser member = data.createUser("协调收文员" + data.uniqueSuffix(), reportOrgId);
|
||||||
|
data.bind(member.userId, role.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Integer> pendingTaskIds(long instanceId) {
|
||||||
|
return data.master().queryForList("SELECT assignee_id FROM approval_task "
|
||||||
|
+ "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body(Integer acceptOrgId, String title, String content, String remark, Long id) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
if (id != null) {
|
||||||
|
sb.append("\"id\":").append(id).append(',');
|
||||||
|
}
|
||||||
|
sb.append("\"acceptOrgId\":").append(acceptOrgId == null ? "null" : acceptOrgId).append(',')
|
||||||
|
.append("\"title\":").append(quote(title)).append(',')
|
||||||
|
.append("\"content\":").append(quote(content)).append(',')
|
||||||
|
.append("\"remark\":").append(remark == null ? "null" : quote(remark))
|
||||||
|
.append('}');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String repeat(String value, int count) {
|
||||||
|
return value.repeat(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String instanceStatus(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bizNo(long instanceId) {
|
||||||
|
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String todayFromDatabase() {
|
||||||
|
return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package com.gxwebsoft.approval;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.gxwebsoft.support.ApprovalTestData;
|
||||||
|
import com.gxwebsoft.support.TestAuthentication;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 待办审批与改派的端到端行为。
|
||||||
|
*
|
||||||
|
* <p>覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、
|
||||||
|
* ASSIGN 处理记录、操作日志以及改派候选用户查询。</p>
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||||
|
class ApprovalPcTodoIT {
|
||||||
|
|
||||||
|
private static final int TENANT = TestAuthentication.TENANT_ID;
|
||||||
|
private static final String LIST = "approval:instance:list";
|
||||||
|
private static final String SUBMIT = "approval:instance:submit";
|
||||||
|
private static final String APPROVE = "approval:instance:approve";
|
||||||
|
private static final String ASSIGN = "approval:instance:assign";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
private ApprovalTestData data;
|
||||||
|
private ApprovalTestData.TestUser reporter;
|
||||||
|
private ApprovalTestData.TestUser leader;
|
||||||
|
private ApprovalTestData.TestUser admin;
|
||||||
|
private Integer organizationId;
|
||||||
|
private final List<String> assignUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
data = new ApprovalTestData(dataSource);
|
||||||
|
organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136);
|
||||||
|
reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId);
|
||||||
|
ApprovalTestData.TestRole leaderRole =
|
||||||
|
data.createRole("dept_leader_" + organizationId, "部门负责人");
|
||||||
|
leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(leader.userId, leaderRole.roleId);
|
||||||
|
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
|
||||||
|
ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.bind(archivist.userId, archivistRole.roleId);
|
||||||
|
admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
for (String url : assignUrls) {
|
||||||
|
data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?",
|
||||||
|
admin.userId, url);
|
||||||
|
}
|
||||||
|
assignUrls.clear();
|
||||||
|
data.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派单据");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
String reason = "原处理人休假" + data.uniqueSuffix();
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.instanceId").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.taskId").value((int) taskId))
|
||||||
|
.andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId))
|
||||||
|
.andExpect(jsonPath("$.data.assigneeId").value(target.userId))
|
||||||
|
.andExpect(jsonPath("$.data.assigneeName").value(target.realName))
|
||||||
|
.andExpect(jsonPath("$.data.reason").value(reason));
|
||||||
|
|
||||||
|
// 任务仍为待处理,归属已换人
|
||||||
|
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo("PENDING");
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(target.userId);
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId))
|
||||||
|
.isEqualTo(target.realName);
|
||||||
|
|
||||||
|
// 处理记录留痕:改派人与原因
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||||
|
+ "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason))
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
|
// 原处理人待办消失、新处理人待办出现
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(0));
|
||||||
|
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.count").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||||
|
|
||||||
|
// 新处理人可正常处理改派后的任务
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||||
|
|
||||||
|
// 原处理人已无权处理该任务
|
||||||
|
long second = createAndSubmit("改派后原处理人");
|
||||||
|
long secondTask = pendingTaskId(second, leader.userId);
|
||||||
|
mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix()))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignToUnknownOrInactiveUser() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派无效目标");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
|
||||||
|
ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'",
|
||||||
|
instanceId)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiresAssignReason() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派无原因");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " "))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignForHandledTask() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派已处理任务");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(1))
|
||||||
|
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAssignWithoutAuthority() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("无权限改派");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
|
||||||
|
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||||
|
.isEqualTo(leader.userId);
|
||||||
|
|
||||||
|
mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"),
|
||||||
|
admin, organizationId, APPROVE))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(403));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesOperationLogWithOriginalAndNewAssignee() throws Exception {
|
||||||
|
long instanceId = createAndSubmit("改派操作日志");
|
||||||
|
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||||
|
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||||
|
String reason = "操作日志原因" + data.uniqueSuffix();
|
||||||
|
String url = "/api/approval/task/" + taskId + "/assign";
|
||||||
|
assignUrls.add(url);
|
||||||
|
|
||||||
|
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
JsonNode record = awaitOperationRecord(url, reason);
|
||||||
|
assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull();
|
||||||
|
assertThat(record.path("userId").asInt()).isEqualTo(admin.userId);
|
||||||
|
assertThat(record.path("description").asText()).contains("改派");
|
||||||
|
assertThat(record.path("params").asText()).contains(reason);
|
||||||
|
assertThat(record.path("result").asText())
|
||||||
|
.contains(leader.realName)
|
||||||
|
.contains(target.realName)
|
||||||
|
.contains(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyActiveUsersForAssignCandidates() throws Exception {
|
||||||
|
ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId);
|
||||||
|
ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId);
|
||||||
|
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||||
|
|
||||||
|
String body = mockMvc.perform(authed(get("/api/approval/directory/users")
|
||||||
|
.param("keywords", active.realName), admin, organizationId, ASSIGN))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
JsonNode users = objectMapper.readTree(body).path("data");
|
||||||
|
assertThat(users.isArray()).isTrue();
|
||||||
|
assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId));
|
||||||
|
assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId));
|
||||||
|
JsonNode matched = null;
|
||||||
|
for (JsonNode user : users) {
|
||||||
|
if (user.path("userId").asInt() == active.userId) {
|
||||||
|
matched = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(matched).isNotNull();
|
||||||
|
assertThat(matched.path("realName").asText()).isEqualTo(active.realName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator,
|
||||||
|
String authority, int targetUserId, String reason)
|
||||||
|
throws Exception {
|
||||||
|
return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long createAndSubmit(String name) throws Exception {
|
||||||
|
String body = "{\"name\":" + quote(name)
|
||||||
|
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
|
||||||
|
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||||
|
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
|
||||||
|
reporter, organizationId, SUBMIT)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
long instanceId = objectMapper.readTree(response).path("data").asLong();
|
||||||
|
data.trackInstance(instanceId);
|
||||||
|
|
||||||
|
uploadAttachment(instanceId);
|
||||||
|
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
|
||||||
|
reporter, organizationId, SUBMIT))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadAttachment(long instanceId) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
|
||||||
|
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
mockMvc.perform(multipart("/api/approval/attachment/upload")
|
||||||
|
.file(file)
|
||||||
|
.param("instanceId", String.valueOf(instanceId))
|
||||||
|
.param("category", "SIGNED")
|
||||||
|
.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
|
||||||
|
organizationId, TENANT, SUBMIT))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingTaskId(long instanceId, int userId) {
|
||||||
|
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||||
|
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
|
||||||
|
assertThat(taskId).as("待办任务应存在").isNotNull();
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志异步写入,最多等待 2 秒。 */
|
||||||
|
private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception {
|
||||||
|
long deadline = System.currentTimeMillis() + 2000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
List<java.util.Map<String, Object>> rows = data.server().queryForList(
|
||||||
|
"SELECT user_id AS userId, description, params, result FROM sys_operation_record "
|
||||||
|
+ "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
|
||||||
|
url, "%" + paramsMark + "%");
|
||||||
|
if (!rows.isEmpty()) {
|
||||||
|
return objectMapper.valueToTree(rows.get(0));
|
||||||
|
}
|
||||||
|
Thread.sleep(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||||
|
ApprovalTestData.TestUser user, Integer org,
|
||||||
|
String... authorities) {
|
||||||
|
return builder.header("tenantId", TENANT)
|
||||||
|
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String quote(String value) {
|
||||||
|
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user