diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java index a3528799..c40b25ac 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/controller/ErpTempTaskController.java @@ -256,13 +256,15 @@ public class ErpTempTaskController extends BaseController { */ /** * 兜底同步任务状态至"待领导审核"。 - * 执行人绕过 submitFinish 直接推进到 leader_final 时,前端加载编辑页主动调此接口修复 taskStatus=3→4。 + * 执行人绕过 submitFinish 直接推进到 leader_final 时,前端加载编辑页主动调此接口修复业务快照。 */ - @SaCheckPermission("oa/erp:tempTask:list") + @SaCheckPermission("oa/erp:tempTask:edit") @PostMapping("/syncState/{tempTaskId}") public R syncState(@NotNull(message = "主键不能为空") - @PathVariable("tempTaskId") Long tempTaskId) { - erpTempTaskService.syncStateToPendingFinal(tempTaskId); + @PathVariable("tempTaskId") Long tempTaskId, + @NotNull(message = "流程任务ID不能为空") + @RequestParam("taskId") Long taskId) { + erpTempTaskService.syncStateToPendingFinal(tempTaskId, taskId); return R.ok(); } diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java index f8f66829..e6280cdf 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/IErpTempTaskService.java @@ -107,12 +107,13 @@ public interface IErpTempTaskService { Boolean leaderReviewAndComplete(ErpTempTaskBo bo); /** - * 兜底同步:流程已在 leader_final 但 taskStatus 仍为 3 时, - * 补齐 totalHours 汇总 + 锁定工时明细 + taskStatus→4(待领导审核)。 + * 兜底同步:流程已在 leader_final 但业务快照未冻结时, + * 校验当前待办后补齐 totalHours 汇总 + 锁定工时明细 + taskStatus→4(待领导审核)。 * * @param tempTaskId 临时任务ID + * @param taskId 当前流程任务ID */ - void syncStateToPendingFinal(Long tempTaskId); + void syncStateToPendingFinal(Long tempTaskId, Long taskId); /** * 查询任务参与人列表(主执行人+协作人) diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java index 68f6c483..9b1bc21f 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpTempTaskServiceImpl.java @@ -251,20 +251,23 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } Long uid = LoginHelper.getUserId(); - // Why:构建 leaderMap(taskId→softwareLeaderId),用于评分可见性判断。 - Map leaderMap = new HashMap<>(taskIds.size()); - for (ErpTempTaskVo record : records) { - leaderMap.put(record.getTempTaskId(), record.getSoftwareLeaderId()); - } // Why:一次 DB 查询拉取所有评分明细,在应用层循环中进行**评分可见性过滤**: - // 领导(softwareLeaderId)看全部评分,被评分人自己只看到自己被评的分数,其他人看不到任何评分记录(AD-16)。 + // 评分人(scorerId)看全部评分,被评分人自己只看到自己被评的分数,其他人看不到任何评分记录(AD-16)。 + // 评分人由 Warm-Flow leader_final 节点的 permissionFlag 硬编码决定,业务代码不存 softwareLeaderId。 // 此处 inline 过滤而非调用 listVisibleScore,避免对每条记录再次查 DB。 List scores = scoreMapper.selectVoList(Wrappers.lambdaQuery() .in(ErpTempTaskScore::getTempTaskId, taskIds)); + // Why:从评分记录的 scorerId 推导"谁是评分人"——评分人能看到该任务下全部评分。 + Set scorerTaskIds = new HashSet<>(); + for (ErpTempTaskScoreVo score : scores) { + if (Objects.equals(uid, score.getScorerId())) { + scorerTaskIds.add(score.getTempTaskId()); + } + } Map> scoreMap = new HashMap<>(taskIds.size()); for (ErpTempTaskScoreVo score : scores) { - // Why:可见性隔离——只有领导(softwareLeaderId)或被评分人本人才能看到评分。 - boolean visible = Objects.equals(uid, leaderMap.get(score.getTempTaskId())) || Objects.equals(uid, score.getUserId()); + // Why:可见性隔离——评分人看全部评分,被评分人本人只看自己的,其他人看不到任何评分。 + boolean visible = scorerTaskIds.contains(score.getTempTaskId()) || Objects.equals(uid, score.getUserId()); if (!visible) { continue; } @@ -596,9 +599,6 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { && bo.getPlanEndTime().before(bo.getPlanStartTime())) { throw new ServiceException("计划完成时间不能早于计划开始时间"); } - if (bo.getSoftwareLeaderId() == null) { - throw new ServiceException("软件部领导不能为空"); - } } /** @@ -639,7 +639,6 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { putIfPresent(variables, "planStartTime", firstNonNull(bo.getPlanStartTime(), sourceVariables.get("planStartTime"))); putIfPresent(variables, "planEndTime", firstNonNull(bo.getPlanEndTime(), sourceVariables.get("planEndTime"))); putIfPresent(variables, "assigneeId", firstNonNull(bo.getAssigneeId(), sourceVariables.get("assigneeId"))); - putIfPresent(variables, "softwareLeaderId", firstNonNull(bo.getSoftwareLeaderId(), sourceVariables.get("softwareLeaderId"))); putIfPresent(variables, "estimateWorkload", firstNonNull(bo.getEstimateWorkload(), sourceVariables.get("estimateWorkload"))); putIfPresent(variables, "assigneeOpinion", firstNonNull(bo.getAssigneeOpinion(), sourceVariables.get("assigneeOpinion"))); putIfPresent(variables, "leaderOpinion", firstNonNull(bo.getLeaderOpinion(), sourceVariables.get("leaderOpinion"))); @@ -800,9 +799,6 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (task == null) { throw new ServiceException("临时任务不存在"); } - if (!Objects.equals(LoginHelper.getUserId(), task.getSoftwareLeaderId())) { - throw new ServiceException("只有软件部领导可以审批调整临时任务"); - } if (bo.getAssigneeId() == null) { throw new ServiceException("进入执行前必须指定主执行人"); } @@ -884,8 +880,6 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { task.setRealRequesterName(bo.getRealRequesterName()); task.setRealRequestDeptId(bo.getRealRequestDeptId()); task.setRealRequestDeptName(bo.getRealRequestDeptName()); - task.setSoftwareLeaderId(bo.getSoftwareLeaderId()); - task.setSoftwareLeaderName(bo.getSoftwareLeaderName()); task.setAssigneeId(bo.getAssigneeId()); task.setAssigneeName(bo.getAssigneeName()); // task.setCcUserIds(bo.getCcUserIds()); @@ -957,8 +951,6 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { bo.setRealRequesterName(task.getRealRequesterName()); bo.setRealRequestDeptId(task.getRealRequestDeptId()); bo.setRealRequestDeptName(task.getRealRequestDeptName()); - bo.setSoftwareLeaderId(task.getSoftwareLeaderId()); - bo.setSoftwareLeaderName(task.getSoftwareLeaderName()); bo.setAssigneeId(task.getAssigneeId()); bo.setAssigneeName(task.getAssigneeName()); bo.setEstimateWorkload(task.getEstimateWorkload()); @@ -1019,7 +1011,13 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { throw new ServiceException("主执行人不可移除,请通过流程换人"); } ErpTempTask task = baseMapper.selectById(member.getTempTaskId()); - if (task != null && !Objects.equals(LoginHelper.getUserId(), task.getAssigneeId())) { + if (task == null) { + throw new ServiceException("临时任务不存在"); + } + if (!STATUS_RUNNING.equals(task.getTaskStatus())) { + throw new ServiceException("仅执行中任务可移除协作人"); + } + if (!Objects.equals(LoginHelper.getUserId(), task.getAssigneeId())) { throw new ServiceException("只有主执行人可以移除协作人"); } return memberMapper.deleteById(memberId) > 0; @@ -1130,10 +1128,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { } // Why:提交前必须累计工时>0,至少一条工时明细。空提交会导致报表统计失去数据基础(AD-14)。 // sumHoursByTask 使用数据库 SUM 聚合,避免应用层循环累加的精度/并发问题。 - BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId); - if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) { - throw new ServiceException("提交完成前任务累计总工时必须大于0,且至少有一条工时明细"); - } + BigDecimal total = requirePositiveTotalHours(tempTaskId); // Why:totalHours 作为提交快照固化到主表,后续评分/报表直接读主表字段,无需再聚合明细(AD-14)。 task.setTotalHours(total); // Why:taskStatus=4(待领导审核) 是提交后的"临时冻结态",整单工时只读不可改(AD-15)。 @@ -1173,19 +1168,27 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { * 兜底同步任务状态至"待领导审核"(taskStatus=4)。 * * Why:执行人可能绕过 submitFinish 直接通过工作流通用审批按钮将流程推到 leader_final, - * 此时 taskStatus 仍为 3(执行中)。本方法在编辑页加载时被前端主动调起, - * 补齐 submitFinish 缺失的聚合 totalHours + 锁定工时明细 + taskStatus→4。 + * 或流程事件先把 taskStatus 置为 4 但未形成 totalHours 快照。本方法在编辑页加载时被前端主动调起, + * 补齐聚合 totalHours + 锁定工时明细 + taskStatus→4。 * - * 幂等保证:仅 STATUS_RUNNING(3) 执行同步,已为 STATUS_PENDING_FINAL(4) 或已关闭(5) 的不重复操作。 - */ + * 幂等保证:仅 STATUS_RUNNING(3)/STATUS_PENDING_FINAL(4) 执行同步,且必须校验 leader_final 当前待办归属。 + */ @Override - public void syncStateToPendingFinal(Long tempTaskId) { + @Transactional(rollbackFor = Exception.class) + public void syncStateToPendingFinal(Long tempTaskId, Long taskId) { + validateWorkflowTaskBelongsToBusiness(tempTaskId, taskId, "leader_final", LoginHelper.getUserId()); ErpTempTask task = baseMapper.selectById(tempTaskId); - if (task == null || !STATUS_RUNNING.equals(task.getTaskStatus())) { - return; + if (task == null) { + throw new ServiceException("临时任务不存在"); } - BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId); - task.setTotalHours(total != null ? total : BigDecimal.ZERO); + if (!STATUS_RUNNING.equals(task.getTaskStatus()) && !STATUS_PENDING_FINAL.equals(task.getTaskStatus())) { + throw new ServiceException("当前任务状态不允许同步至待领导审核"); + } + BigDecimal total = task.getTotalHours(); + if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) { + total = requirePositiveTotalHours(tempTaskId); + } + task.setTotalHours(total); task.setTaskStatus(STATUS_PENDING_FINAL); worklogMapper.lockByTask(tempTaskId, "1"); baseMapper.updateById(task); @@ -1203,18 +1206,12 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (task == null) { throw new ServiceException("临时任务不存在"); } - if (!Objects.equals(LoginHelper.getUserId(), task.getSoftwareLeaderId())) { - throw new ServiceException("只有软件部领导可以评分关闭"); + // Why:评分关闭只能消费已提交完成形成的冻结快照,不能在执行中直接评分关闭。 + if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) { + throw new ServiceException("仅待领导审核状态可评分关闭,请先提交完成"); } - // Why:正常情况下 taskStatus 应由 submitFinish 置为 4(待领导审核),但执行人可能绕过 submitFinish - // 直接通过工作流通用审批按钮将流程推到 leader_final,此时 taskStatus 仍为 3(执行中)。 - // 对于 taskStatus=3 但流程已在 leader_final 的情况,此处兜底补齐 submitFinish 的落库动作。 - if (STATUS_RUNNING.equals(task.getTaskStatus())) { - BigDecimal total = worklogMapper.sumHoursByTask(bo.getTempTaskId()); - task.setTotalHours(total != null ? total : BigDecimal.ZERO); - worklogMapper.lockByTask(bo.getTempTaskId(), "1"); - } else if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) { - throw new ServiceException("仅待领导审核或执行中状态可评分关闭"); + if (task.getTotalHours() == null || task.getTotalHours().compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("任务未形成有效工时提交快照,不能评分关闭"); } // Why:必须对全部参与人(主执行人+协作人)都给出评分等级,不允许遗漏任何人。漏评会导致考核不公(AD-16)。 List members = memberMapper.selectList(Wrappers.lambdaQuery() @@ -1248,6 +1245,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { return Boolean.TRUE; } + private BigDecimal requirePositiveTotalHours(Long tempTaskId) { + BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId); + if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("提交完成前任务累计总工时必须大于0,且至少有一条工时明细"); + } + return total; + } + private void validateScoreCoverage(ErpTempTaskScoreSubmitBo bo, List members) { if (members == null || members.isEmpty()) { throw new ServiceException("任务参与人不存在,无法评分关闭"); @@ -1302,12 +1307,13 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { /** * Why:校验前端传入的 taskId 确实属于当前临时任务、处于预期节点、且当前用户是办理人。 * 通过 Warm-Flow 的 flowHisTaskList 查询给定 businessId 下的所有流程任务历史列表, - * 然后**线性搜索**匹配 taskId,找到后校验 nodeCode 和 approver 是否与预期一致。 + * 然后**线性搜索**匹配 taskId,找到后校验 flowStatus、nodeCode 和 approver 是否与预期一致。 * 线性搜索而非 Map 的原因是:流程任务列表通常较短(10条以内),O(n) 足够且代码更清晰直接。 * 三重校验: - * 1. taskId 必须存在于该临时任务的历史任务列表中(防止跨业务窜用 taskId) - * 2. nodeCode 必须匹配预期节点(防止在错误的审批节点办理,如本该 leader_review 却在 execute 节点调用) - * 3. 当前登录人必须在 approver 列表中(防止越权代办) + * 1. taskId 必须存在于该临时任务的流程任务列表中(防止跨业务窜用 taskId) + * 2. flowStatus 必须为 waiting(防止拿历史已办 taskId 重放业务动作) + * 3. nodeCode 必须匹配预期节点(防止在错误的审批节点办理,如本该 leader_review 却在 execute 节点调用) + * 4. 当前登录人必须在 approver 列表中(防止越权代办) * 任意一项不匹配均抛 ServiceException,在 @GlobalTransactional 外层触发 Seata 回滚。 */ @SuppressWarnings("unchecked") @@ -1332,6 +1338,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { if (!Objects.equals(taskIdText, Convert.toStr(taskMap.get("id")))) { continue; } + // Why:只允许当前待办任务驱动业务动作。历史已办 taskId 即便 businessId/nodeCode/approver 匹配,也不能重放同步或审批。 + if (!Objects.equals(BusinessStatusEnum.WAITING.getStatus(), Convert.toStr(taskMap.get("flowStatus")))) { + throw new ServiceException("流程任务不是当前待办,不能执行该业务操作"); + } // Why:nodeCode 精确匹配——例如 scoreAndClose 要求 nodeCode="leader_final", // submitFinish 要求 nodeCode="execute",防止在错误节点调用业务接口。 if (StringUtils.isNotBlank(expectedNodeCode) @@ -1361,13 +1371,18 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService { */ @Override public List listVisibleScore(Long tempTaskId) { - ErpTempTask task = baseMapper.selectById(tempTaskId); - if (task == null) { + if (tempTaskId == null) { return Collections.emptyList(); } Long uid = LoginHelper.getUserId(); + // Why:评分人由 Warm-Flow leader_final 节点 permissionFlag 硬编码决定,业务代码不存 softwareLeaderId。 + // 判断当前用户是否为评分人:查该任务下是否存在 scorerId == uid 的评分记录。 + // 评分人看全部评分,被评分人只能看自己被评的分数(AD-16)。 + boolean isScorer = scoreMapper.selectCount(Wrappers.lambdaQuery() + .eq(ErpTempTaskScore::getTempTaskId, tempTaskId) + .eq(ErpTempTaskScore::getScorerId, uid)) > 0; List list; - if (Objects.equals(uid, task.getSoftwareLeaderId())) { + if (isScorer) { list = scoreMapper.selectVoList(Wrappers.lambdaQuery() .eq(ErpTempTaskScore::getTempTaskId, tempTaskId)); } else {