fix(approval): 派单归属受理部门负责人、PC 全租户待办改派与 keywords 统一

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