feat(project): 添加项目进度自动计算功能
- 在 ProjectMapper 中新增 updateProgressRateById 方法,支持单独更新项目进度 - 在 ProjectService 中新增 recalcProgress 接口,用于重新计算项目进度 - 实现 ProjectServiceImpl 的进度重计算逻辑,通过根任务平均进度更新项目进度 - 新增 ProjectTaskMapper 的 selectRootTaskAvgProgressByProjectId 查询方法 - 在任务创建、更新、删除、状态变更等操作后触发项目进度重计算 - 添加进度归一化处理,确保数值精度为两位小数 - 更新 CLAUDE.md 文档,加强技术风险判断要求
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 风险",被追问才收回。
|
||||
|
||||
## 本机环境
|
||||
|
||||
|
||||
@@ -73,4 +73,14 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
||||
.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<ProjectDO>()
|
||||
.eq(ProjectDO::getId, id));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -191,6 +191,26 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
@Param("executionIds") Collection<Long> executionIds,
|
||||
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||
|
||||
/**
|
||||
* 项目进度推算:跨执行聚合,按项目下所有根任务 progressRate 简单平均;无根任务时 SQL 返回 null。
|
||||
* 与执行口径一致(parent_task_id IS NULL + excludedStatusCodes),区别仅是不限定 execution_id。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT AVG(COALESCE(progress_rate, 0))
|
||||
FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND parent_task_id IS NULL
|
||||
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
|
||||
AND status_code NOT IN
|
||||
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
|
||||
</if>
|
||||
</script>
|
||||
""")
|
||||
BigDecimal selectRootTaskAvgProgressByProjectId(@Param("projectId") Long projectId,
|
||||
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||
|
||||
/**
|
||||
* 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。
|
||||
* 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径(execution_id + parent_task_id IS NULL + excludedStatusCodes)。
|
||||
|
||||
@@ -60,4 +60,10 @@ public interface ProjectService {
|
||||
|
||||
void autoStartProjectIfPending(Long projectId, String triggerAction);
|
||||
|
||||
/**
|
||||
* 重算项目进度并落库:AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。
|
||||
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。
|
||||
*/
|
||||
void recalcProgress(Long projectId);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String> 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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user