merge: code review 修复(approval-fix)

This commit is contained in:
2026-10-30 22:29:23 +08:00
26 changed files with 317 additions and 82 deletions
@@ -23,7 +23,7 @@ public final class ApprovalConstants {
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_ARCHIVED = "ARCHIVED";
/** 发起人可编辑、可提交的状态。 */
/** 报送人可编辑、可提交的状态。 */
public static final Set<String> EDITABLE_STATUSES =
Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList(
STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN)));
@@ -72,6 +72,14 @@ public final class ApprovalConstants {
public static final String ROLE_USER = "user";
public static final String ROLE_GUEST = "guest";
// 权限串(与 spec 约定一致;菜单/按钮共用)
public static final String PERMISSION_LIST = "approval:instance:list";
public static final String PERMISSION_SUBMIT = "approval:instance:submit";
public static final String PERMISSION_APPROVE = "approval:instance:approve";
public static final String PERMISSION_ARCHIVE = "approval:instance:archive";
public static final String PERMISSION_ASSIGN = "approval:instance:assign";
public static final String PERMISSION_EXPORT = "approval:instance:export";
// 附件规则
public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
public static final int MAX_ATTACHMENT_COUNT = 9;
@@ -141,11 +149,16 @@ public final class ApprovalConstants {
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
/** 内部协调单流程定义(version=1,表单与派单由后续迭代启用)。 */
/**
* 内部协调单流程定义(version=2)。
*
* <p>version=1 的派单节点按受理部门全员生成任务,导致普通成员也能派单且部门无成员时卡单;
* v2 改为受理部门负责人(或签)。版本不可变:已发布实例仍引用其定义版本。</p>
*/
public static final String COORDINATION_NODES_JSON = "["
+ "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"},"
+ "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgMembers\",\"name\":\"派单\"},"
+ "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgLeader\",\"name\":\"派单\"},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"},"
+ "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ "]";
@@ -18,5 +18,6 @@ public interface ApprovalExportMapper {
*/
List<ApprovalExportRow> selectExportRows(@Param("param") ApprovalQueryParam param,
@Param("userId") Integer userId,
@Param("allVisible") Integer allVisible);
@Param("allVisible") Integer allVisible,
@Param("assignVisible") Integer assignVisible);
}
@@ -24,7 +24,8 @@ public interface ApprovalInstanceMapper extends BaseMapper<ApprovalInstance> {
*/
int countVisible(@Param("id") Long id,
@Param("userId") Integer userId,
@Param("allVisible") Integer allVisible);
@Param("allVisible") Integer allVisible,
@Param("assignVisible") Integer assignVisible);
/**
* 我发起的列表。
@@ -18,5 +18,6 @@ public interface ApprovalQueryMapper {
List<ApprovalItemRow> selectInstancePage(@Param("page") IPage<ApprovalItemRow> page,
@Param("param") ApprovalQueryParam param,
@Param("userId") Integer userId,
@Param("allVisible") Integer allVisible);
@Param("allVisible") Integer allVisible,
@Param("assignVisible") Integer assignVisible);
}
@@ -22,6 +22,12 @@ public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
@Param("userId") Integer userId,
@Param("taskStatus") String taskStatus);
/**
* 全租户待办列表:任一 PENDING 任务对应的实例,按实例去重。
*/
List<ApprovalItemRow> selectPendingInstancePage(@Param("page") IPage<ApprovalItemRow> page,
@Param("param") ApprovalTaskListParam param);
/**
* 本人待办实例数量(同一实例多任务只算一条)。
*/
@@ -31,6 +31,7 @@
i.reporter_id = #{userId}
OR (i.status != 'DRAFT' AND (
#{allVisible} = 1
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
OR i.dispatch_target_id = #{userId}
@@ -54,11 +55,11 @@
<if test="param.status != null and param.status != ''">
AND i.status = #{param.status}
</if>
<if test="param.keyword != null and param.keyword != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keyword}, '%')
OR f.name LIKE CONCAT('%', #{param.keyword}, '%')
OR co.title LIKE CONCAT('%', #{param.keyword}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keyword}, '%'))
<if test="param.keywords != null and param.keywords != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
</if>
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
AND i.submit_time &gt;= #{param.submitTimeStart}
@@ -17,6 +17,7 @@
i.reporter_id = #{userId}
OR (i.status != 'DRAFT' AND (
#{allVisible} = 1
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
OR i.dispatch_target_id = #{userId}
@@ -2,7 +2,7 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalQueryMapper">
<!-- PC 单据查询:数据权限条件与详情保持一致(草稿仅发起人可见) -->
<!-- PC 单据查询:数据权限条件与详情保持一致(草稿仅报送人可见) -->
<select id="selectInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
SELECT i.id,
i.biz_no,
@@ -24,6 +24,7 @@
i.reporter_id = #{userId}
OR (i.status != 'DRAFT' AND (
#{allVisible} = 1
OR (#{assignVisible} = 1 AND i.status = 'PENDING')
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
OR i.dispatch_target_id = #{userId}
@@ -47,11 +48,11 @@
<if test="param.status != null and param.status != ''">
AND i.status = #{param.status}
</if>
<if test="param.keyword != null and param.keyword != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keyword}, '%')
OR f.name LIKE CONCAT('%', #{param.keyword}, '%')
OR co.title LIKE CONCAT('%', #{param.keyword}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keyword}, '%'))
<if test="param.keywords != null and param.keywords != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
</if>
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
AND i.submit_time &gt;= #{param.submitTimeStart}
@@ -40,6 +40,42 @@
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
</select>
<select id="selectPendingInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
SELECT i.id,
i.biz_no,
i.doc_type,
i.status,
i.reporter_id,
i.reporter_name,
i.report_org_name,
i.accept_org_name,
i.archive_time,
i.current_node_key,
i.definition_id,
i.submit_time,
COALESCE(f.name, co.title) AS title
FROM approval_instance i
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
WHERE EXISTS (
SELECT 1 FROM approval_task t
WHERE t.instance_id = i.id AND t.status = 'PENDING'
)
<if test="param.status != null and param.status != ''">
AND i.status = #{param.status}
</if>
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.keywords != null and param.keywords != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
</if>
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
</select>
<select id="countPendingInstances" resultType="java.lang.Integer">
SELECT COUNT(DISTINCT instance_id)
FROM approval_task
@@ -8,7 +8,7 @@ import java.io.Serializable;
/**
* 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。
*
* <p>报送部门与经办人由服务端按登录态带出,不在参数中接收。</p>
* <p>报送部门与报送人由服务端按登录态带出,不在参数中接收。</p>
*/
@Data
public class ApprovalCoordinationParam implements Serializable {
@@ -21,16 +21,13 @@ public class ApprovalQueryParam extends BaseParam {
@ApiModelProperty("单号")
private String bizNo;
@ApiModelProperty("关键词:单号、名称或标题、实施单位")
private String keyword;
@ApiModelProperty("报送部门id")
private Integer reportOrgId;
@ApiModelProperty("受理部门id")
private Integer acceptOrgId;
@ApiModelProperty("发起人id")
@ApiModelProperty("报送人id")
private Integer reporterId;
@ApiModelProperty("单据状态")
@@ -17,4 +17,8 @@ public class ApprovalTaskListParam extends BaseParam {
@ApiModelProperty("单据类型")
private String docType;
/** 查询范围:mine(默认,本人待办/已办)或 all(全租户待办,需 assign 权限)。 */
@ApiModelProperty("查询范围 mine/all")
private String scope;
}
@@ -87,7 +87,7 @@ public class ApprovalAttachmentService {
}
/**
* 上传附件:仅发起人且实例可编辑时可上传。
* 上传附件:仅报送人且实例可编辑时可上传。
*/
public ApprovalAttachmentVO upload(Long instanceId, MultipartFile file, String category, User user) {
ApprovalInstance instance = instanceMapper.selectById(instanceId);
@@ -95,11 +95,6 @@ public class ApprovalDirectoryService {
USER_MAPPER, tenantId, tenantId, tenantId, roleCode, tenantId);
}
/** 部门负责人角色是否已配置至少一名启用成员。 */
public boolean hasDepartmentLeader(Integer organizationId) {
return organizationId != null && !listRoleMembers("dept_leader_" + organizationId).isEmpty();
}
/** 启用用户搜索,最多 50 条。 */
public List<ApprovalUserVO> searchUsers(String keywords, Integer organizationId) {
Integer tenantId = ApprovalTenantContext.currentTenantId();
@@ -9,6 +9,7 @@ import com.gxwebsoft.approval.support.ApprovalNode;
import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.FileRecord;
import com.gxwebsoft.common.system.entity.Menu;
import com.gxwebsoft.common.system.entity.User;
import org.apache.poi.xssf.streaming.SXSSFRow;
import org.apache.poi.xssf.streaming.SXSSFSheet;
@@ -75,7 +76,9 @@ public class ApprovalExportService {
public ApprovalExportResult export(ApprovalQueryParam param, User user) throws IOException {
int allVisible = directoryService.hasAnyRole(user.getUserId(),
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
List<ApprovalExportRow> rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible);
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
List<ApprovalExportRow> rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible,
assignVisible);
if (rows.size() > MAX_EXPORT_ROWS) {
throw new BusinessException("请缩小筛选范围");
}
@@ -105,6 +108,15 @@ public class ApprovalExportService {
return new ApprovalExportResult(record, rows.size());
}
private boolean hasAuthority(User user, String authority) {
if (user.getAuthorities() == null) {
return false;
}
return user.getAuthorities().stream()
.map(Menu::getAuthority)
.anyMatch(authority::equals);
}
private void writeWorkbook(List<ApprovalExportRow> rows, File target) throws IOException {
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
try {
@@ -69,17 +69,6 @@ public class ApprovalFileService {
return fileRecord;
}
public FileRecord getById(Integer id) {
if (id == null) {
return null;
}
List<FileRecord> records = jdbcTemplate.query(
"SELECT id, name, path, length, content_type, create_user_id, deleted FROM sys_file_record "
+ "WHERE id = ? AND tenant_id = ? AND deleted = 0",
FILE_MAPPER, id, ApprovalTenantContext.currentTenantId());
return records.isEmpty() ? null : records.get(0);
}
public List<FileRecord> listByIds(Collection<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
@@ -14,16 +14,30 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 流程定义服务:按租户幂等发布 version=1,并解析节点 JSON。
* 流程定义服务:按租户幂等发布各流程的最新版本,并解析节点 JSON。
*
* <p>版本不可变:流程调整只新增版本行,已创建实例继续引用其 definition_id;
* {@link #getOrCreate(String)} 始终返回最新版本。</p>
*/
@Service
public class ApprovalFlowService {
private static final Logger log = LoggerFactory.getLogger(ApprovalFlowService.class);
private static final int INITIAL_VERSION = 1;
/** 各流程当前发布版本;调整节点时必须递增并保留旧版本行。 */
private static final Map<String, Integer> LATEST_VERSIONS;
static {
Map<String, Integer> versions = new LinkedHashMap<>();
versions.put(ApprovalConstants.FLOW_COMPLETION, 1);
versions.put(ApprovalConstants.FLOW_COORDINATION_INTERNAL, 2);
LATEST_VERSIONS = Collections.unmodifiableMap(versions);
}
@Resource
private ApprovalFlowDefinitionMapper flowDefinitionMapper;
@@ -31,18 +45,19 @@ public class ApprovalFlowService {
private ObjectMapper objectMapper;
/**
* 获取租户下的流程定义;不存在时创建 version=1。
* 获取租户下流程定义的最新版本;尚无该版本时发布新版。
*
* <p>并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新查询。</p>
* <p>并发首次创建依赖唯一键 uk_flow_ver 兜底,冲突后重新读取最新版本。</p>
*/
public ApprovalFlowDefinition getOrCreate(String flowCode) {
ApprovalFlowDefinition definition = find(flowCode);
if (definition != null) {
int latestVersion = latestVersion(flowCode);
ApprovalFlowDefinition definition = findLatest(flowCode);
if (definition != null && definition.getVersion() >= latestVersion) {
return definition;
}
ApprovalFlowDefinition created = new ApprovalFlowDefinition();
created.setFlowCode(flowCode);
created.setVersion(INITIAL_VERSION);
created.setVersion(latestVersion);
created.setName(flowName(flowCode));
created.setNodesJson(flowNodesJson(flowCode));
try {
@@ -50,8 +65,8 @@ public class ApprovalFlowService {
return created;
} catch (DuplicateKeyException e) {
log.info("审批流程定义已被并发创建,重新读取: {}", flowCode);
definition = find(flowCode);
if (definition == null) {
definition = findLatest(flowCode);
if (definition == null || definition.getVersion() < latestVersion) {
throw new BusinessException("审批流程定义初始化失败");
}
return definition;
@@ -92,13 +107,18 @@ public class ApprovalFlowService {
return node == null ? null : node.getName();
}
private ApprovalFlowDefinition find(String flowCode) {
private ApprovalFlowDefinition findLatest(String flowCode) {
return flowDefinitionMapper.selectOne(new LambdaQueryWrapper<ApprovalFlowDefinition>()
.eq(ApprovalFlowDefinition::getFlowCode, flowCode)
.eq(ApprovalFlowDefinition::getVersion, INITIAL_VERSION)
.orderByDesc(ApprovalFlowDefinition::getVersion)
.last("LIMIT 1"));
}
private int latestVersion(String flowCode) {
Integer version = LATEST_VERSIONS.get(flowCode);
return version == null ? INITIAL_VERSION : version;
}
private String flowName(String flowCode) {
if (ApprovalConstants.FLOW_COMPLETION.equals(flowCode)) {
return "业务完成确认单";
@@ -27,6 +27,7 @@ import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
import com.gxwebsoft.approval.vo.ApprovalItemVO;
import com.gxwebsoft.approval.vo.ApprovalMyTaskVO;
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
import com.gxwebsoft.approval.vo.ApprovalPendingTaskVO;
import com.gxwebsoft.approval.vo.ApprovalPermissionsVO;
import com.gxwebsoft.approval.vo.ApprovalRecordVO;
import com.gxwebsoft.approval.vo.ApprovalUserVO;
@@ -34,6 +35,7 @@ import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Menu;
import com.gxwebsoft.common.system.entity.User;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
@@ -59,7 +61,9 @@ import java.util.stream.Collectors;
public class ApprovalInstanceService {
private static final BigDecimal MAX_FEE = new BigDecimal("99999999.99");
private static final String PERMISSION_ASSIGN = "approval:instance:assign";
private static final String PERMISSION_ASSIGN = ApprovalConstants.PERMISSION_ASSIGN;
/** 待办范围:全租户待办(仅 assign 权限可用)。 */
private static final String SCOPE_ALL = "all";
@Resource
private ApprovalInstanceMapper instanceMapper;
@@ -103,7 +107,7 @@ public class ApprovalInstanceService {
/**
* 新建或更新内部协调单草稿,返回实例id。
*
* <p>报送部门与经办人取登录态;受理部门必须是启用组织,保存时即校验。</p>
* <p>报送部门与报送人取登录态;受理部门必须是启用组织,保存时即校验。</p>
*/
public Long saveCoordination(ApprovalCoordinationParam param, User user) {
validateCoordination(param);
@@ -233,7 +237,7 @@ public class ApprovalInstanceService {
}
/**
* 详情;草稿仅发起人可见,其余按任务/记录/派单目标或 allVisible 角色判断。
* 详情;草稿仅报送人可见,其余按任务/记录/派单目标或 allVisible 角色判断。
*/
public ApprovalInstanceVO detail(Long instanceId, User user) {
ApprovalInstance instance = instanceMapper.selectById(instanceId);
@@ -242,7 +246,9 @@ public class ApprovalInstanceService {
}
int allVisible = directoryService.hasAnyRole(user.getUserId(),
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
if (instanceMapper.countVisible(instanceId, user.getUserId(), allVisible) == 0) {
int assignVisible = hasAuthority(user, PERMISSION_ASSIGN)
&& ApprovalConstants.STATUS_PENDING.equals(instance.getStatus()) ? 1 : 0;
if (instanceMapper.countVisible(instanceId, user.getUserId(), allVisible, assignVisible) == 0) {
throw new BusinessException("无权查看该单据");
}
@@ -267,6 +273,7 @@ public class ApprovalInstanceService {
vo.setConfirms(confirms(instance));
ApprovalMyTaskVO myTask = myTask(instance, user.getUserId());
vo.setMyTask(myTask);
vo.setPendingTasks(pendingTasks(instance));
vo.setPermissions(permissions(instance, user, myTask));
return vo;
}
@@ -284,8 +291,17 @@ public class ApprovalInstanceService {
/**
* 我的待办:本人 PENDING 任务对应的实例,按实例去重。
*
* <p>PC 传 {@code scope=all} 时返回全租户待办,仅 assign 权限可用;
* 员工端不传 scope,行为不变。</p>
*/
public PageResult<ApprovalItemVO> todo(ApprovalTaskListParam param, User user) {
if (SCOPE_ALL.equals(param.getScope())) {
if (!hasAuthority(user, PERMISSION_ASSIGN)) {
throw new AccessDeniedException("无权限查看全部待办");
}
return pendingPage(param);
}
return taskPage(param, user, ApprovalConstants.TASK_PENDING);
}
@@ -296,6 +312,17 @@ public class ApprovalInstanceService {
return taskPage(param, user, ApprovalConstants.TASK_DONE);
}
/**
* 全租户待办:任一 PENDING 任务对应的实例,按实例去重。
*/
private PageResult<ApprovalItemVO> pendingPage(ApprovalTaskListParam param) {
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
Page<ApprovalItemRow> page = new Page<>(current, size);
List<ApprovalItemRow> rows = taskMapper.selectPendingInstancePage(page, param);
return new PageResult<>(toItems(rows), page.getTotal());
}
private PageResult<ApprovalItemVO> taskPage(ApprovalTaskListParam param, User user, String taskStatus) {
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
@@ -552,6 +579,28 @@ public class ApprovalInstanceService {
return vo;
}
private List<ApprovalPendingTaskVO> pendingTasks(ApprovalInstance instance) {
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
return new ArrayList<>();
}
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
.eq(ApprovalTask::getInstanceId, instance.getId())
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)
.orderByAsc(ApprovalTask::getId));
List<ApprovalPendingTaskVO> result = new ArrayList<>(tasks.size());
for (ApprovalTask task : tasks) {
ApprovalPendingTaskVO vo = new ApprovalPendingTaskVO();
vo.setId(task.getId());
vo.setNodeKey(task.getNodeKey());
vo.setNodeName(flowService.nodeName(instance.getDefinitionId(), task.getNodeKey()));
vo.setNodeType(task.getNodeType());
vo.setAssigneeId(task.getAssigneeId());
vo.setAssigneeName(task.getAssigneeName());
result.add(vo);
}
return result;
}
private ApprovalPermissionsVO permissions(ApprovalInstance instance, User user, ApprovalMyTaskVO myTask) {
ApprovalPermissionsVO permissions = new ApprovalPermissionsVO();
boolean reporter = instance.getReporterId().equals(user.getUserId());
@@ -565,6 +614,8 @@ public class ApprovalInstanceService {
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
permissions.setCanWithdraw(done == null || done == 0);
}
permissions.setCanAssign(hasAuthority(user, PERMISSION_ASSIGN)
&& ApprovalConstants.STATUS_PENDING.equals(instance.getStatus()));
if (myTask != null) {
boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType());
boolean rejectableNode = approveNode || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
@@ -573,7 +624,6 @@ public class ApprovalInstanceService {
permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()));
permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType()));
permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType()));
permissions.setCanAssign(hasAuthority(user, PERMISSION_ASSIGN));
}
return permissions;
}
@@ -8,6 +8,7 @@ import com.gxwebsoft.approval.support.ApprovalItemRow;
import com.gxwebsoft.approval.support.ApprovalNode;
import com.gxwebsoft.approval.vo.ApprovalItemVO;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Menu;
import com.gxwebsoft.common.system.entity.User;
import org.springframework.stereotype.Service;
@@ -44,8 +45,10 @@ public class ApprovalQueryService {
long size = param.getLimit() == null ? DEFAULT_LIMIT : Math.max(1, param.getLimit());
int allVisible = directoryService.hasAnyRole(user.getUserId(),
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
Page<ApprovalItemRow> page = new Page<>(current, size);
List<ApprovalItemRow> rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible);
List<ApprovalItemRow> rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible,
assignVisible);
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
List<ApprovalItemVO> list = new ArrayList<>(rows.size());
for (ApprovalItemRow row : rows) {
@@ -73,6 +76,15 @@ public class ApprovalQueryService {
return item;
}
private boolean hasAuthority(User user, String authority) {
if (user.getAuthorities() == null) {
return false;
}
return user.getAuthorities().stream()
.map(Menu::getAuthority)
.anyMatch(authority::equals);
}
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
if (definitionId == null || nodeKey == null) {
return null;
@@ -88,5 +88,8 @@ public class ApprovalInstanceVO implements Serializable {
private ApprovalMyTaskVO myTask;
/** 实例当前全部待办任务,供有 assign 权限的 PC 用户按任务改派。 */
private List<ApprovalPendingTaskVO> pendingTasks;
private ApprovalPermissionsVO permissions;
}
@@ -0,0 +1,27 @@
package com.gxwebsoft.approval.vo;
import io.swagger.annotations.ApiModel;
import lombok.Data;
import java.io.Serializable;
/**
* 实例上的待办任务(PC 改派按任务 id 操作)。
*/
@Data
@ApiModel(description = "实例待办任务")
public class ApprovalPendingTaskVO implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String nodeKey;
private String nodeName;
private String nodeType;
private Integer assigneeId;
private String assigneeName;
}
@@ -120,17 +120,22 @@ class ApprovalCoordinationIT {
.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);
// 流程定义按租户幂等发布最新版本,节点顺序固定;v1 保留不改,v2 派单归受理部门负责人
assertThat(data.queryMaster("SELECT MAX(version) FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal'", Integer.class, TENANT)).isEqualTo(2);
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);
+ "WHERE tenant_id = ? AND flow_code = 'coordination_internal' "
+ "ORDER BY version DESC LIMIT 1", String.class, TENANT);
assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch")
.contains("confirm").contains("archive");
assertThat(nodes).contains("\"type\": \"DISPATCH\", \"resolver\": \"acceptOrgLeader\"");
// 实例固定引用创建时的定义版本(v2)
assertThat(data.queryMaster("SELECT definition_version FROM approval_instance WHERE id = ?",
Integer.class, instanceId)).isEqualTo(2);
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 + "}";
@@ -350,7 +355,10 @@ class ApprovalCoordinationIT {
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(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
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))
@@ -122,6 +122,12 @@ class ApprovalDispatchIT {
long instanceId = reachDispatch("部门候选池");
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
// 派单节点只落在受理部门负责人(或签),普通部门成员不产生派单待办
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
// 部门内停用成员不进入候选池
ApprovalTestData.TestUser disabled =
data.createUser("派单停用成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
@@ -158,6 +164,11 @@ class ApprovalDispatchIT {
// 受理部门成员全部调离后部门为空,派单待办仍在负责人手中
data.setUserOrganization(acceptLeader.userId, reportOrgId);
data.setUserOrganization(acceptMember.userId, reportOrgId);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH' AND status = 'PENDING'",
instanceId, acceptLeader.userId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND node_type = 'DISPATCH'", instanceId, acceptMember.userId)).isZero();
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
acceptLeader, reportOrgId, APPROVE))
@@ -179,7 +190,6 @@ class ApprovalDispatchIT {
void dispatchesToSelectedCandidateAndCreatesBothConfirmTasks() throws Exception {
long instanceId = reachDispatch("派单存档");
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
long otherDispatchTask = pendingTaskId(instanceId, acceptMember.userId);
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
acceptLeader, acceptOrgId, APPROVE)
@@ -193,13 +203,13 @@ class ApprovalDispatchIT {
.andExpect(jsonPath("$.data.dispatchTargetName").value(acceptMember.realName))
.andExpect(jsonPath("$.data.myTask").doesNotExist());
// 派单任务完成并快照派单对象,同节点其余派单待办取消
// 派单任务完成并快照派单对象;派单节点只有负责人的一条或签待办
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask))
.isEqualTo("DONE");
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_task WHERE id = ?",
Integer.class, dispatchTask)).isEqualTo(acceptMember.userId);
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, otherDispatchTask))
.isEqualTo("CANCELLED");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
Integer.class, instanceId)).isEqualTo(acceptMember.userId);
assertThat(data.queryMaster("SELECT dispatch_target_name FROM approval_instance WHERE id = ?",
@@ -273,7 +283,6 @@ class ApprovalDispatchIT {
void concurrentDispatchOnlySucceedsOnce() throws Exception {
long instanceId = reachDispatch("并发派单");
long leaderTask = pendingTaskId(instanceId, acceptLeader.userId);
long memberTask = pendingTaskId(instanceId, acceptMember.userId);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
@@ -283,7 +292,7 @@ class ApprovalDispatchIT {
try {
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
acceptMember.userId, success, conflict);
dispatchAsync(executor, start, done, memberTask, acceptMember, acceptOrgId,
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
acceptLeader.userId, success, conflict);
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
@@ -298,7 +307,7 @@ class ApprovalDispatchIT {
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH' AND status = 'DONE'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'DISPATCH' AND status = 'CANCELLED'", instanceId)).isEqualTo(1);
+ "AND node_type = 'DISPATCH'", instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
}
@@ -139,7 +139,7 @@ class ApprovalExportIT {
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围")));
@@ -164,7 +164,7 @@ class ApprovalExportIT {
String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
@@ -244,7 +244,7 @@ class ApprovalExportIT {
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.content("{\"keywords\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条")));
@@ -281,10 +281,10 @@ class ApprovalExportIT {
return null;
}
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keyword) throws Exception {
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keywords) throws Exception {
String response = mockMvc.perform(authed(post(URL), user, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(keyword) + "}"))
.content("{\"keywords\":" + quote(keywords) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
@@ -142,6 +142,54 @@ class ApprovalPcTodoIT {
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
}
@Test
void listsAllTenantPendingTasksForAssignUserAndReassignsOthers() throws Exception {
String title = "全租户待办" + data.uniqueSuffix();
long instanceId = createAndSubmit(title);
long taskId = pendingTaskId(instanceId, leader.userId);
// 有 assign 权限的 PC 用户通过 scope=all 看到他人待办
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all")
.param("keywords", title), admin, organizationId, LIST, ASSIGN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
// 非处理人也能打开详情,并按任务 id 拿到待办任务与改派权限
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), admin, organizationId, LIST, ASSIGN))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.permissions.canAssign").value(true))
.andExpect(jsonPath("$.data.myTask").doesNotExist())
.andExpect(jsonPath("$.data.pendingTasks.length()").value(1))
.andExpect(jsonPath("$.data.pendingTasks[0].id").value((int) taskId))
.andExpect(jsonPath("$.data.pendingTasks[0].assigneeId").value(leader.userId))
.andExpect(jsonPath("$.data.pendingTasks[0].nodeName").value("报送部门负责人"));
// 按待办任务 id 改派成功,待办转移到新处理人
ApprovalTestData.TestUser target = data.createUser("全租户改派目标" + data.uniqueSuffix(), organizationId);
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "全租户改派" + data.uniqueSuffix()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.assigneeId").value(target.userId));
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
}
@Test
void rejectsScopeAllWithoutAssignAuthority() throws Exception {
long instanceId = createAndSubmit("全租户越权" + data.uniqueSuffix());
assertThat(instanceId).isPositive();
mockMvc.perform(authed(get("/api/approval/todo/page").param("scope", "all"), admin, organizationId, LIST))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void rejectsAssignToUnknownOrInactiveUser() throws Exception {
long instanceId = createAndSubmit("改派无效目标");
@@ -81,25 +81,25 @@ class ApprovalQueryIT {
long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
// 单据类型 + 关键词命中实施单位
assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted);
assertThat(queryIds("?docType=completion&keywords=实施部")).containsExactly(submitted);
// 关键词命中协调单标题
assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination);
assertThat(queryIds("?docType=coordination&keywords=维修协调")).containsExactly(coordination);
// 关键词命中单号
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted);
assertThat(queryIds("?keywords=" + bizNo)).containsExactly(submitted);
// 报送部门:两类单据都按报送部门过滤,其它部门不命中
assertThat(queryIds("?reportOrgId=" + reportOrgId))
.containsExactlyInAnyOrder(submitted, coordination, draft);
assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
// 受理部门
assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
// 发起人
// 报送人
assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
// 状态
assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
// 总数与分页总数一致
assertThat(queryCount("?keyword=实施部")).isEqualTo(1);
assertThat(queryCount("?keywords=实施部")).isEqualTo(1);
// 单号精确筛选
assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
// 提交时间范围
@@ -142,7 +142,7 @@ class ApprovalQueryIT {
@Test
void limitsListByDataPermission() throws Exception {
long draft = createCompletionDraft("仅发起人可见草稿");
long draft = createCompletionDraft("仅报送人可见草稿");
long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
@@ -161,7 +161,7 @@ class ApprovalQueryIT {
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
data.bind(archivist.userId, archivistRole.roleId);
// 发起人可见自己的草稿与在途单据
// 报送人可见自己的草稿与在途单据
assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
// 参与人:有任务、有处理记录、被派单
assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
@@ -170,7 +170,7 @@ class ApprovalQueryIT {
// 无关人员看不到他人单据
assertThat(queryIdsAs(outsider, "")).isEmpty();
assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
// 收文员看全租户,但草稿仍只属于发起人
// 收文员看全租户,但草稿仍只属于报送人
assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
}