feat(project): 添加项目完成前置校验功能
- 新增 PROJECT_COMPLETE_PRECONDITION_NOT_MET 错误码用于项目完成校验失败提示 - 将任务附件错误码段从 1_008_007 调整至 1_008_010 避免编号冲突 - 添加 PROJECT_ACTION_COMPLETE 常量用于项目完成操作标识 - 在执行完成时触发 onExecutionCompleted 钩子方法用于后续推送逻辑 - 新增 countNonTerminalByProjectId 方法统计项目下非终态执行/需求/任务数量 - 实现 collectCompletionGaps 和 validateProjectCompletable 方法进行项目完成前置校验 - 在项目状态变更时增加对 complete 操作的特殊校验逻辑 - 添加 ProjectRequirementConstants 接口暴露需求对象类型常量供跨类使用 - 新建 SQL 脚本为项目完成校验查询创建必要的数据库索引 - 补充 ProjectServiceImplTest 测试用例验证项目完成校验功能
This commit is contained in:
@@ -112,7 +112,9 @@
|
|||||||
"Bash(Select-Object FullName)",
|
"Bash(Select-Object FullName)",
|
||||||
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test \"-Dtest=ProjectExecutionServiceImplTest#changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow\" 2>&1 | Select-String -Pattern \"BUILD|Tests run|FAIL|ERROR|passed\" | Select-Object -First 20)",
|
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test \"-Dtest=ProjectExecutionServiceImplTest#changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow\" 2>&1 | Select-String -Pattern \"BUILD|Tests run|FAIL|ERROR|passed\" | Select-Object -First 20)",
|
||||||
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -q -pl rdms-project/rdms-project-boot -am compile 2>&1 | Select-Object -Last 20)",
|
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -q -pl rdms-project/rdms-project-boot -am compile 2>&1 | Select-Object -Last 20)",
|
||||||
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test -Dtest=ProjectServiceImplTest 2>&1 | Select-Object -Last 40)"
|
"PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test -Dtest=ProjectServiceImplTest 2>&1 | Select-Object -Last 40)",
|
||||||
|
"Bash(xargs wc -l)",
|
||||||
|
"Bash(C:/software/mysql-8.4.9-winx64/bin/mysql.exe *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_002_036, "批量新增不允许指定为经理,请通过编辑成员调整");
|
ErrorCode PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_002_036, "批量新增不允许指定为经理,请通过编辑成员调整");
|
||||||
// 批量移出(POST /project/project/{id}/members/batch/inactive)专用:同一请求内 memberId 重复
|
// 批量移出(POST /project/project/{id}/members/batch/inactive)专用:同一请求内 memberId 重复
|
||||||
ErrorCode PROJECT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE = new ErrorCode(1_008_002_037, "请勿在批量移出列表中重复指定同一成员");
|
ErrorCode PROJECT_MEMBER_BATCH_INACTIVE_MEMBER_DUPLICATE = new ErrorCode(1_008_002_037, "请勿在批量移出列表中重复指定同一成员");
|
||||||
|
// 项目完成前置校验(TD-015):complete 时子对象(任务/执行/需求)未全终态。{} 由 Service 动态拼全部缺口,只放中文+数字(TD-012 脱敏)
|
||||||
|
ErrorCode PROJECT_COMPLETE_PRECONDITION_NOT_MET = new ErrorCode(1_008_002_038, "项目无法完成,请先处理:{}");
|
||||||
|
|
||||||
// ========== 执行管理 1-008-003-000 ==========
|
// ========== 执行管理 1-008-003-000 ==========
|
||||||
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
|
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
|
||||||
@@ -186,12 +188,12 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC = new ErrorCode(1_008_006_010, "工时进度与日期顺序不一致:早段进度不得高于晚段、晚段进度不得低于早段");
|
ErrorCode PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC = new ErrorCode(1_008_006_010, "工时进度与日期顺序不一致:早段进度不得高于晚段、晚段进度不得低于早段");
|
||||||
ErrorCode PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID = new ErrorCode(1_008_006_011, "完成难度不在字典范围内");
|
ErrorCode PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID = new ErrorCode(1_008_006_011, "完成难度不在字典范围内");
|
||||||
|
|
||||||
// ========== 任务 / 工时附件 1_008_007_xxx ==========
|
// ========== 任务 / 工时附件 1_008_010_xxx(原 1_008_007 与下方项目需求段撞号,迁至独立号段;新增错误码域请从 1_008_011 起) ==========
|
||||||
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个");
|
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_010_001, "附件数量不能超过 {} 个");
|
||||||
ErrorCode PROJECT_TASK_ATTACHMENT_URL_INVALID = new ErrorCode(1_008_007_002, "附件地址非法,必须为 http/https URL 且长度不超过 1024");
|
ErrorCode PROJECT_TASK_ATTACHMENT_URL_INVALID = new ErrorCode(1_008_010_002, "附件地址非法,必须为 http/https URL 且长度不超过 1024");
|
||||||
ErrorCode PROJECT_TASK_ATTACHMENT_NAME_INVALID = new ErrorCode(1_008_007_003, "附件文件名不合法(必填且长度不超过 255)");
|
ErrorCode PROJECT_TASK_ATTACHMENT_NAME_INVALID = new ErrorCode(1_008_010_003, "附件文件名不合法(必填且长度不超过 255)");
|
||||||
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_007_004, "附件扩展名【{}】不在允许列表内");
|
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_010_004, "附件扩展名【{}】不在允许列表内");
|
||||||
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_007_005, "附件类型【{}】被禁止上传");
|
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_010_005, "附件类型【{}】被禁止上传");
|
||||||
|
|
||||||
// ========== 项目需求 1_008_007_xxx ==========
|
// ========== 项目需求 1_008_007_xxx ==========
|
||||||
ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在");
|
ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在");
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public final class ObjectActivityConstants {
|
|||||||
public static final String PROJECT_ACTION_DELETE = "delete";
|
public static final String PROJECT_ACTION_DELETE = "delete";
|
||||||
public static final String PROJECT_ACTION_CHANGE_MANAGER = "change_manager";
|
public static final String PROJECT_ACTION_CHANGE_MANAGER = "change_manager";
|
||||||
public static final String PROJECT_ACTION_AUTO_START = "auto_start";
|
public static final String PROJECT_ACTION_AUTO_START = "auto_start";
|
||||||
|
public static final String PROJECT_ACTION_COMPLETE = "complete";
|
||||||
|
|
||||||
// ========== 项目自动推进触发动作 ==========
|
// ========== 项目自动推进触发动作 ==========
|
||||||
public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution";
|
public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution";
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.njcn.rdms.module.project.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目需求(project_requirement)相关常量。
|
||||||
|
* object_type 原为 ProjectRequirementServiceImpl 私有常量,TD-015 因项目层完成校验需跨类引用,提升为公共常量。
|
||||||
|
*/
|
||||||
|
public interface ProjectRequirementConstants {
|
||||||
|
|
||||||
|
/** 状态机 / 权限体系中的对象类型标识 */
|
||||||
|
String OBJECT_TYPE = "project_requirement";
|
||||||
|
}
|
||||||
@@ -92,4 +92,17 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
|
|||||||
.eq(ProjectRequirementDO::getStatusCode, statusCode));
|
.eq(ProjectRequirementDO::getStatusCode, statusCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定项目下处于非终态的需求数。用于项目 complete 前置校验(TD-015)。
|
||||||
|
* 走索引 idx_rdms_project_requirement_proj_status(project_id, status_code, deleted)(见演示库补丁补齐)。
|
||||||
|
*/
|
||||||
|
default Integer countNonTerminalByProjectId(Long projectId, List<String> terminalStatusCodes) {
|
||||||
|
LambdaQueryWrapperX<ProjectRequirementDO> queryWrapper = new LambdaQueryWrapperX<ProjectRequirementDO>()
|
||||||
|
.eq(ProjectRequirementDO::getProjectId, projectId);
|
||||||
|
if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) {
|
||||||
|
queryWrapper.notIn(ProjectRequirementDO::getStatusCode, terminalStatusCodes);
|
||||||
|
}
|
||||||
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
|
|||||||
return Math.toIntExact(selectCount(queryWrapper));
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定项目下处于非终态的执行数。用于项目 complete 前置校验(TD-015)。
|
||||||
|
*/
|
||||||
|
default Integer countNonTerminalByProjectId(Long projectId, List<String> terminalStatusCodes) {
|
||||||
|
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
|
||||||
|
.eq(ProjectExecutionDO::getProjectId, projectId);
|
||||||
|
if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) {
|
||||||
|
queryWrapper.notIn(ProjectExecutionDO::getStatusCode, terminalStatusCodes);
|
||||||
|
}
|
||||||
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
|
}
|
||||||
|
|
||||||
default Integer countByProjectIdAndStatusCode(Long projectId,
|
default Integer countByProjectIdAndStatusCode(Long projectId,
|
||||||
ProjectExecutionStatusBoardReqVO reqVO,
|
ProjectExecutionStatusBoardReqVO reqVO,
|
||||||
String statusCode,
|
String statusCode,
|
||||||
|
|||||||
@@ -149,6 +149,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
|||||||
return Math.toIntExact(selectCount(queryWrapper));
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计指定项目下处于非终态的任务数。用于项目 complete 前置校验(TD-015,方案 B 进度维度)。
|
||||||
|
*/
|
||||||
|
default Integer countNonTerminalByProjectId(Long projectId, List<String> terminalStatusCodes) {
|
||||||
|
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||||
|
.eq(ProjectTaskDO::getProjectId, projectId);
|
||||||
|
if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) {
|
||||||
|
queryWrapper.notIn(ProjectTaskDO::getStatusCode, terminalStatusCodes);
|
||||||
|
}
|
||||||
|
return Math.toIntExact(selectCount(queryWrapper));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。
|
* 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
|||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||||
|
import com.njcn.rdms.module.project.constant.ProjectRequirementConstants;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementAllowedTransitionBatchRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementAllowedTransitionBatchRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementCloseReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementCloseReqVO;
|
||||||
@@ -63,7 +64,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
|||||||
@Service
|
@Service
|
||||||
public class ProjectRequirementServiceImpl implements ProjectRequirementService {
|
public class ProjectRequirementServiceImpl implements ProjectRequirementService {
|
||||||
|
|
||||||
private static final String REQUIREMENT_OBJECT_TYPE = "project_requirement";
|
private static final String REQUIREMENT_OBJECT_TYPE = ProjectRequirementConstants.OBJECT_TYPE;
|
||||||
private static final String PROJECT_OBJECT_TYPE = ProjectObjectConstants.OBJECT_TYPE;
|
private static final String PROJECT_OBJECT_TYPE = ProjectObjectConstants.OBJECT_TYPE;
|
||||||
|
|
||||||
private static final String STATUS_PENDING_CLAIM = "pending_claim";
|
private static final String STATUS_PENDING_CLAIM = "pending_claim";
|
||||||
@@ -354,6 +355,17 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
|
|||||||
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus,
|
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus,
|
||||||
buildRequirementFieldChanges(before, requirement), reason);
|
buildRequirementFieldChanges(before, requirement), reason);
|
||||||
refreshAncestorStatusAndSyncProduct(requirement.getId());
|
refreshAncestorStatusAndSyncProduct(requirement.getId());
|
||||||
|
onRequirementStatusChanged(requirement, toStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TD-015 推模式钩子:需求状态变更后触发,留作后续推送 / 催办的挂载点。
|
||||||
|
* 本期空占位:不在此查终态码(避免每次需求状态变更白查一次 DB);接入通知时再在这里查 terminal_flag
|
||||||
|
* 判断 toStatus 是否终态、再触发所属项目「能否完成」判定。终态判定走 DB、不硬编码,与需求状态机后续简化解耦。
|
||||||
|
*/
|
||||||
|
private void onRequirementStatusChanged(ProjectRequirementDO requirement, String toStatus) {
|
||||||
|
// TODO(TD-015 推模式):目标状态为终态时,通知项目负责人项目可能已满足完成条件
|
||||||
|
//(该类未配 @Slf4j;接入时可用 requirement.getProjectId() / toStatus 补观测点)。
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateReviewStatusAction(String actionCode) {
|
private void validateReviewStatusAction(String actionCode) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
|||||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||||
|
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||||
|
import com.njcn.rdms.module.project.constant.ProjectRequirementConstants;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
|
||||||
@@ -115,6 +117,10 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
@Resource
|
@Resource
|
||||||
private ProjectTaskMapper projectTaskMapper;
|
private ProjectTaskMapper projectTaskMapper;
|
||||||
@Resource
|
@Resource
|
||||||
|
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
|
||||||
|
@Resource
|
||||||
|
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
|
||||||
|
@Resource
|
||||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectDataScopeService objectDataScopeService;
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
@@ -591,6 +597,9 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
|
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
|
||||||
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
|
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
|
||||||
}
|
}
|
||||||
|
if (ObjectActivityConstants.PROJECT_ACTION_COMPLETE.equals(actionCode)) {
|
||||||
|
validateProjectCompletable(project.getId());
|
||||||
|
}
|
||||||
changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
|
changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +694,51 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
return progress.setScale(2, RoundingMode.HALF_UP);
|
return progress.setScale(2, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集项目「无法完成」的全部缺口(TD-015)。一次性列全,避免用户修完一个才发现还有另一个。
|
||||||
|
* 三个维度统一「数非终态子对象」:任务(方案 B 进度维度)/ 执行 / 需求;终态码全查 DB(terminal_flag),不硬编码。
|
||||||
|
* 返回空串 = 满足完成条件。
|
||||||
|
*/
|
||||||
|
private String collectCompletionGaps(Long projectId) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
|
||||||
|
List<String> taskTerminal = objectStatusModelMapper
|
||||||
|
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||||
|
Integer openTasks = projectTaskMapper.countNonTerminalByProjectId(projectId, taskTerminal);
|
||||||
|
if (openTasks != null && openTasks > 0) {
|
||||||
|
parts.add(openTasks + " 个任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> execTerminal = objectStatusModelMapper
|
||||||
|
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
|
||||||
|
Integer openExecs = projectExecutionMapper.countNonTerminalByProjectId(projectId, execTerminal);
|
||||||
|
if (openExecs != null && openExecs > 0) {
|
||||||
|
parts.add(openExecs + " 个执行");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> reqTerminal = objectStatusModelMapper
|
||||||
|
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectRequirementConstants.OBJECT_TYPE);
|
||||||
|
Integer openReqs = projectRequirementMapper.countNonTerminalByProjectId(projectId, reqTerminal);
|
||||||
|
if (openReqs != null && openReqs > 0) {
|
||||||
|
parts.add(openReqs + " 个需求");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "仍有 " + String.join("、", parts) + "未完成。";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目 complete 前置硬校验(TD-015)。缺口非空则一次性抛全部;message 只放中文+数字(TD-012 脱敏)。
|
||||||
|
*/
|
||||||
|
private void validateProjectCompletable(Long projectId) {
|
||||||
|
String gaps = collectCompletionGaps(projectId);
|
||||||
|
if (!gaps.isEmpty()) {
|
||||||
|
throw exception(ErrorCodeConstants.PROJECT_COMPLETE_PRECONDITION_NOT_MET, gaps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void validateCreateReqVO(ProjectSaveReqVO createReqVO) {
|
void validateCreateReqVO(ProjectSaveReqVO createReqVO) {
|
||||||
validateProjectCodeUnique(null, createReqVO.getProjectCode());
|
validateProjectCodeUnique(null, createReqVO.getProjectCode());
|
||||||
@@ -1041,7 +1095,8 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {
|
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {
|
||||||
// 项目上下文返回的是主表快照叠加运行时生命周期读模型,状态名称和动作列表不在主表写死。
|
// 项目上下文返回的是主表快照叠加运行时生命周期读模型,状态名称和动作列表不在主表写死。
|
||||||
ProjectContextProjectRespVO currentProject = BeanUtils.toBean(project, ProjectContextProjectRespVO.class);
|
ProjectContextProjectRespVO currentProject = BeanUtils.toBean(project, ProjectContextProjectRespVO.class);
|
||||||
ProjectStatusViewService.ProjectLifecycleView lifecycle = projectStatusViewService.getLifecycle(project.getStatusCode());
|
ProjectStatusViewService.ProjectLifecycleView lifecycle =
|
||||||
|
projectStatusViewService.getLifecycle(project.getStatusCode());
|
||||||
currentProject.setStatusName(lifecycle.statusName());
|
currentProject.setStatusName(lifecycle.statusName());
|
||||||
currentProject.setTerminal(lifecycle.terminal());
|
currentProject.setTerminal(lifecycle.terminal());
|
||||||
currentProject.setAllowEdit(lifecycle.allowEdit());
|
currentProject.setAllowEdit(lifecycle.allowEdit());
|
||||||
|
|||||||
@@ -508,12 +508,27 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
// 完成动作:兜底把执行进度刷到 100%
|
// 完成动作:兜底把执行进度刷到 100%
|
||||||
if ("complete".equals(actionCode)) {
|
if ("complete".equals(actionCode)) {
|
||||||
forceCompleteProgress(execution);
|
forceCompleteProgress(execution);
|
||||||
|
onExecutionCompleted(execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消 / 暂停 / 恢复:级联执行下任务
|
// 取消 / 暂停 / 恢复:级联执行下任务
|
||||||
cascadeTasksIfNeeded(executionId, actionCode, reason);
|
cascadeTasksIfNeeded(executionId, actionCode, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TD-015 推模式钩子:执行完成后触发。本期仅占位(通知 / 催办待后续独立实现):
|
||||||
|
* 1) 触发所属项目「能否完成」判定(项目层 complete 按钮即时联动);
|
||||||
|
* 2) 若该执行关联了项目需求,触发需求催办。
|
||||||
|
*/
|
||||||
|
private void onExecutionCompleted(ProjectExecutionDO execution) {
|
||||||
|
// TODO(TD-015 推模式):项目完成判定钩子 —— 通知项目负责人项目可能已满足完成条件
|
||||||
|
if (execution.getProjectRequirementId() != null) {
|
||||||
|
// TODO(TD-015 推模式):需求催办钩子 —— 提醒处理人推进关联需求至终态
|
||||||
|
}
|
||||||
|
log.debug("[TD-015] 执行完成钩子占位: executionId={}, projectId={}, projectRequirementId={}",
|
||||||
|
execution.getId(), execution.getProjectId(), execution.getProjectRequirementId());
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
ProjectDO validateEditableProject(Long projectId) {
|
ProjectDO validateEditableProject(Long projectId) {
|
||||||
ProjectDO project = validateProjectExists(projectId);
|
ProjectDO project = validateProjectExists(projectId);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- TD-015 项目完成校验:按 project_id 数非终态子对象需要的索引
|
||||||
|
-- MySQL 无 CREATE INDEX IF NOT EXISTS,用 information_schema 守卫实现幂等(可重复执行);与演示库补丁 docs/sql/patches/2026-06-05-TD015-完成校验-01.sql 写法一致
|
||||||
|
|
||||||
|
-- 需求表:原 idx_rdms_project_requirement_project_status_deleted 实际列为 (status_code, deleted),缺 project_id 前导,补齐
|
||||||
|
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'rdms_project_requirement'
|
||||||
|
AND INDEX_NAME = 'idx_rdms_project_requirement_proj_status');
|
||||||
|
SET @sql := IF(@idx = 0,
|
||||||
|
'CREATE INDEX idx_rdms_project_requirement_proj_status ON rdms_project_requirement (project_id, status_code, deleted)',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
-- 任务表(rdms_task):与执行表 idx_rdms_pe_proj_status 对齐,让按项目数非终态任务走覆盖前缀
|
||||||
|
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'rdms_task'
|
||||||
|
AND INDEX_NAME = 'idx_rdms_task_proj_status');
|
||||||
|
SET @sql := IF(@idx = 0,
|
||||||
|
'CREATE INDEX idx_rdms_task_proj_status ON rdms_task (project_id, status_code, deleted)',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||||
|
|
||||||
|
-- 执行表 rdms_project_execution 已有 idx_rdms_pe_proj_status(project_id, status_code, deleted),无需新增
|
||||||
@@ -55,6 +55,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
@@ -91,6 +92,12 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
private ObjectDataScopeService objectDataScopeService;
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
@Mock
|
@Mock
|
||||||
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
|
||||||
|
@Mock
|
||||||
|
private com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper projectTaskMapper;
|
||||||
|
@Mock
|
||||||
|
private com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper projectExecutionMapper;
|
||||||
|
@Mock
|
||||||
|
private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper projectRequirementMapper;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
||||||
@@ -507,6 +514,67 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals("paused", statusCaptor.getValue().getToStatus());
|
assertEquals("paused", statusCaptor.getValue().getToStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeProjectStatus_complete_whenChildrenNotAllTerminal_listsAllGaps() {
|
||||||
|
Long projectId = 3001L;
|
||||||
|
ProjectDO project = createProject(projectId, 1001L, "项目X", 2001L, "active");
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
// 终态码按 object_type 查 DB(这里直接 stub),count 决定缺口
|
||||||
|
when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("completed", "cancelled"));
|
||||||
|
when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("execution"))
|
||||||
|
.thenReturn(List.of("completed", "cancelled"));
|
||||||
|
when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("project_requirement"))
|
||||||
|
.thenReturn(List.of("closed", "cancelled", "rejected"));
|
||||||
|
when(projectTaskMapper.countNonTerminalByProjectId(any(), any())).thenReturn(2);
|
||||||
|
when(projectExecutionMapper.countNonTerminalByProjectId(any(), any())).thenReturn(1);
|
||||||
|
when(projectRequirementMapper.countNonTerminalByProjectId(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO();
|
||||||
|
reqVO.setId(projectId);
|
||||||
|
reqVO.setActionCode("complete");
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> projectService.changeProjectStatus(reqVO));
|
||||||
|
assertEquals(ErrorCodeConstants.PROJECT_COMPLETE_PRECONDITION_NOT_MET.getCode(), ex.getCode());
|
||||||
|
// 一次性列全三类缺口
|
||||||
|
assertTrue(ex.getMessage().contains("2 个任务"));
|
||||||
|
assertTrue(ex.getMessage().contains("1 个执行"));
|
||||||
|
assertTrue(ex.getMessage().contains("1 个需求"));
|
||||||
|
// 脱敏:不得泄漏表名 / status_code
|
||||||
|
assertTrue(!ex.getMessage().contains("rdms_"));
|
||||||
|
assertTrue(!ex.getMessage().contains("status_code"));
|
||||||
|
// 校验不通过:不得写库
|
||||||
|
verify(projectMapper, never()).updateStatusByIdAndStatus(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeProjectStatus_complete_whenAllChildrenTerminal_passes() {
|
||||||
|
Long projectId = 3002L;
|
||||||
|
ProjectDO project = createProject(projectId, 1001L, "项目Y", 2001L, "active");
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(any()))
|
||||||
|
.thenReturn(List.of("completed", "cancelled"));
|
||||||
|
when(projectTaskMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
|
||||||
|
when(projectExecutionMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
|
||||||
|
when(projectRequirementMapper.countNonTerminalByProjectId(any(), any())).thenReturn(0);
|
||||||
|
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("project", "active", "complete"))
|
||||||
|
.thenReturn(createTransition("complete", "completed", false));
|
||||||
|
when(projectMapper.updateStatusByIdAndStatus(eq(projectId), eq("active"), eq("completed"), any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
|
||||||
|
ProjectStatusActionReqVO reqVO = new ProjectStatusActionReqVO();
|
||||||
|
reqVO.setId(projectId);
|
||||||
|
reqVO.setActionCode("complete");
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3003L, "操作人")) {
|
||||||
|
projectService.changeProjectStatus(reqVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(projectMapper, times(1))
|
||||||
|
.updateStatusByIdAndStatus(eq(projectId), eq("active"), eq("completed"), any());
|
||||||
|
}
|
||||||
|
|
||||||
private ProjectSaveReqVO createReqVO(String code, Long productId, String projectName, Long managerUserId) {
|
private ProjectSaveReqVO createReqVO(String code, Long productId, String projectName, Long managerUserId) {
|
||||||
ProjectSaveReqVO reqVO = new ProjectSaveReqVO();
|
ProjectSaveReqVO reqVO = new ProjectSaveReqVO();
|
||||||
reqVO.setProjectCode(code);
|
reqVO.setProjectCode(code);
|
||||||
|
|||||||
Reference in New Issue
Block a user