diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 57353c3..723aba9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -108,7 +108,8 @@ "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)", "Bash(grep -E \"\\\\.\\(sql|java|md\\)$\")", "Bash(xargs grep -l \"INSERT INTO.*system_menu\")", - "Bash(Get-ChildItem *)" + "Bash(Get-ChildItem *)", + "Bash(Select-Object FullName)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 2060d1d..a322719 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ - 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。 - **输出极简**:先给结论、改动点、必要风险;用自然语言给判断和影响面,少贴代码片段;涉及代码用 `file_path:line_number` 引用;用户追问再展开。 - **下定论需要充足证据**。疑似 bug 时先判断是否稳定复现:跑了很久没动过的功能**首次**报错,优先怀疑运行时状态污染(devtools / IDE 热替换、ApplicationContext 残留、缓存、Redis / DB 连接、JVM 静态字段被旧 context 设过等),**不要凭单次堆栈就断言代码 bug,更不要直接甩修改方案**。先给"可能原因 + 最便宜的取证步骤"(多数场景是**冷重启 JVM**),用户确认能稳定复现,再讨论代码层面的修复。同款写法在仓库其它位置存在并不能反推"也是 bug",长期能跑的代码突然失效 ≠ 代码本身错。 +- **技术风险判断(性能 / N+1 / 索引缺失 / 架构缺陷 / 并发安全 / 内存泄漏 等)与"bug 判断"同等严格**:未读到实现层不下结论。不要凭 subagent 摘要、字段名、注释或印象"顺嘴提一句风险/瓶颈/可能问题"——那也是下结论,**且杀伤力更大**:用户会基于"风险提示"决定要不要立项整改。如果当前上下文没核实到实现,就明说"这部分未核实,需要打开 X 文件确认",不要把猜测包装成"风险提示"塞出去。已识别教训:执行进度查询答完"已批量聚合无 N+1"后又凭印象抛"列表 N+1 风险",被追问才收回。 ## 本机环境 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java index a9f48a5..edf9fbf 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java @@ -73,4 +73,14 @@ public interface ProjectMapper extends BaseMapperX { .eq(ProjectDO::getStatusCode, statusCode)); } + /** + * 仅更新项目 progressRate,不动其他字段(避免污染 lastStatusReason 等)。 + */ + default int updateProgressRateById(Long id, java.math.BigDecimal progressRate) { + ProjectDO update = new ProjectDO(); + update.setProgressRate(progressRate); + return update(update, new LambdaQueryWrapperX() + .eq(ProjectDO::getId, id)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index 89efb30..aaac4e7 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -191,6 +191,26 @@ public interface ProjectTaskMapper extends BaseMapperX { @Param("executionIds") Collection executionIds, @Param("excludedStatusCodes") Collection excludedStatusCodes); + /** + * 项目进度推算:跨执行聚合,按项目下所有根任务 progressRate 简单平均;无根任务时 SQL 返回 null。 + * 与执行口径一致(parent_task_id IS NULL + excludedStatusCodes),区别仅是不限定 execution_id。 + */ + @Select(""" + + """) + BigDecimal selectRootTaskAvgProgressByProjectId(@Param("projectId") Long projectId, + @Param("excludedStatusCodes") Collection excludedStatusCodes); + /** * 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。 * 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径(execution_id + parent_task_id IS NULL + excludedStatusCodes)。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java index 6b80697..3f40869 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectService.java @@ -60,4 +60,10 @@ public interface ProjectService { void autoStartProjectIfPending(Long projectId, String triggerAction); + /** + * 重算项目进度并落库:AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。 + * 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。 + */ + void recalcProgress(Long projectId); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java index 6b27868..afa9c89 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -32,9 +32,11 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransition import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; @@ -58,6 +60,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -108,6 +111,8 @@ class ProjectServiceImpl implements ProjectService { @Resource private ProjectRequirementModuleMapper projectRequirementModuleMapper; @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; @Resource private ObjectDataScopeService objectDataScopeService; @@ -634,6 +639,39 @@ class ProjectServiceImpl implements ProjectService { changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason); } + @Override + public void recalcProgress(Long projectId) { + if (projectId == null) { + return; + } + ProjectDO project = projectMapper.selectById(projectId); + if (project == null) { + // 项目已被删除(删除项目时其下任务已不可达,无须再触发;此处兜底,避免上游误调) + return; + } + List excludedStatusCodes = objectStatusModelMapper + .selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + BigDecimal newProgress = normalizeProgress(projectTaskMapper + .selectRootTaskAvgProgressByProjectId(projectId, + excludedStatusCodes == null ? Collections.emptyList() : excludedStatusCodes)); + // 与当前缓存值数值相等则跳过 UPDATE,避免不必要的写与审计字段抖动 + BigDecimal current = project.getProgressRate(); + if (current != null && current.compareTo(newProgress) == 0) { + return; + } + projectMapper.updateProgressRateById(projectId, newProgress); + } + + /** + * 进度归一化:null → 0.00,非 null → scale=2 HALF_UP。与执行/任务层口径一致。 + */ + private BigDecimal normalizeProgress(BigDecimal progress) { + if (progress == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return progress.setScale(2, RoundingMode.HALF_UP); + } + @VisibleForTesting void validateCreateReqVO(ProjectSaveReqVO createReqVO) { validateProjectCodeUnique(null, createReqVO.getProjectCode()); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index eb7ee05..e46bdc2 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -177,6 +177,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (task.getParentTaskId() != null) { recalcParentProgressFrom(task.getParentTaskId()); } + // 项目进度推算:无论新建的是根任务(进度=0 进入项目均值)还是子任务(冒泡后根任务进度可能变化), + // 项目均值都可能发生变化,统一触发一次项目层重算(覆盖根任务边界) + projectService.recalcProgress(projectId); writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_CREATE, null, task.getStatusCode(), buildTaskFieldChanges(null, task), null); @@ -242,6 +245,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { parentsToRecalc.add(newParentId); } parentsToRecalc.forEach(this::recalcParentProgressFrom); + // 项目进度推算:updateTask 可能改变 parent_task_id,使 task 进入/离开"根任务集合", + // 项目均值的分量构成可能变化;无论 progressRate 是否变化都需触发刷新 + projectService.recalcProgress(projectId); writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_UPDATE, before.getStatusCode(), task.getStatusCode(), buildTaskFieldChanges(before, task), null); @@ -285,6 +291,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (parentTaskId != null) { recalcParentProgressFrom(parentTaskId); } + // 项目进度推算:无论删除的是根任务(项目分量减一)还是子任务(父链冒泡到根后根任务进度变化), + // 项目均值都可能发生变化,统一触发一次项目层重算(覆盖根任务边界) + projectService.recalcProgress(task.getProjectId()); writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); } @@ -365,6 +374,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (task.getParentTaskId() != null) { recalcParentProgressFrom(task.getParentTaskId()); } + // 项目进度推算:worklog 驱动的任务进度变化(叶子任务),无论叶子任务是否同时是根任务都需触发 + projectService.recalcProgress(task.getProjectId()); } @Override @@ -894,11 +905,16 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason); maybeFillActualDates(task, fromStatus, toStatus); - // 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算 + // 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算(forceCompleteProgress 内部触发项目刷新) if ("complete".equals(actionCode)) { forceCompleteProgress(task); - } else if (task.getParentTaskId() != null) { - recalcParentProgressFrom(task.getParentTaskId()); + } else { + if (task.getParentTaskId() != null) { + recalcParentProgressFrom(task.getParentTaskId()); + } + // 项目进度推算:非完成状态变更(cancel/pause/resume)。cancel 进 progress_excluded 影响项目均值; + // pause/resume 不影响 progress_excluded,但根任务的 pause/resume 不改 progressRate 时 recalc 也是幂等的 + projectService.recalcProgress(task.getProjectId()); } // 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树) @@ -918,6 +934,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { private void forceCompleteProgress(ProjectTaskDO task) { BigDecimal full = BigDecimal.valueOf(100); if (progressNumericallyEquals(task.getProgressRate(), full)) { + // 进度已经是 100:completed 不在 progress_excluded 集合内,对项目均值无影响,跳过刷新 return; } projectTaskMapper.updateProgressRateById(task.getId(), full); @@ -925,6 +942,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (task.getParentTaskId() != null) { recalcParentProgressFrom(task.getParentTaskId()); } + // 项目进度推算:根任务 complete 时父链不冒泡,但任务自身 progressRate=100 已变化,必须触发刷新 + projectService.recalcProgress(task.getProjectId()); } /**