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:
2026-06-08 09:59:22 +08:00
parent 622b30733e
commit ab5b00470c
12 changed files with 236 additions and 9 deletions

View File

@@ -39,6 +39,7 @@ public final class ObjectActivityConstants {
public static final String PROJECT_ACTION_DELETE = "delete";
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_COMPLETE = "complete";
// ========== 项目自动推进触发动作 ==========
public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution";

View File

@@ -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";
}

View File

@@ -92,4 +92,17 @@ public interface ProjectRequirementMapper extends BaseMapperX<ProjectRequirement
.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));
}
}

View File

@@ -143,6 +143,18 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
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,
ProjectExecutionStatusBoardReqVO reqVO,
String statusCode,

View File

@@ -149,6 +149,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
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));
}
/**
* 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。
*/

View File

@@ -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.module.project.constant.ProjectExecutionConstants;
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.ProjectRequirementBatchReqVO;
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
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 STATUS_PENDING_CLAIM = "pending_claim";
@@ -354,6 +355,17 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus,
buildRequirementFieldChanges(before, requirement), reason);
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) {

View File

@@ -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.ProductObjectConstants;
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.project.ProjectContextNavRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
@@ -115,6 +117,10 @@ class ProjectServiceImpl implements ProjectService {
@Resource
private ProjectTaskMapper projectTaskMapper;
@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;
@Resource
private ObjectDataScopeService objectDataScopeService;
@@ -591,6 +597,9 @@ class ProjectServiceImpl implements ProjectService {
statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()),
statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode));
}
if (ObjectActivityConstants.PROJECT_ACTION_COMPLETE.equals(actionCode)) {
validateProjectCompletable(project.getId());
}
changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
}
@@ -685,6 +694,51 @@ class ProjectServiceImpl implements ProjectService {
return progress.setScale(2, RoundingMode.HALF_UP);
}
/**
* 收集项目「无法完成」的全部缺口TD-015。一次性列全避免用户修完一个才发现还有另一个。
* 三个维度统一「数非终态子对象」:任务(方案 B 进度维度)/ 执行 / 需求;终态码全查 DBterminal_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
void validateCreateReqVO(ProjectSaveReqVO createReqVO) {
validateProjectCodeUnique(null, createReqVO.getProjectCode());
@@ -1041,7 +1095,8 @@ class ProjectServiceImpl implements ProjectService {
private ProjectContextProjectRespVO buildCurrentProject(ProjectDO project) {
// 项目上下文返回的是主表快照叠加运行时生命周期读模型,状态名称和动作列表不在主表写死。
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.setTerminal(lifecycle.terminal());
currentProject.setAllowEdit(lifecycle.allowEdit());

View File

@@ -508,12 +508,27 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
// 完成动作:兜底把执行进度刷到 100%
if ("complete".equals(actionCode)) {
forceCompleteProgress(execution);
onExecutionCompleted(execution);
}
// 取消 / 暂停 / 恢复:级联执行下任务
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
ProjectDO validateEditableProject(Long projectId) {
ProjectDO project = validateProjectExists(projectId);

View File

@@ -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),无需新增

View File

@@ -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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -91,6 +92,12 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
private ObjectDataScopeService objectDataScopeService;
@Mock
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
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
@@ -507,6 +514,67 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
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这里直接 stubcount 决定缺口
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) {
ProjectSaveReqVO reqVO = new ProjectSaveReqVO();
reqVO.setProjectCode(code);