diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 510babb..c1a381c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -112,7 +112,9 @@ "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' -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 *)" ] } } diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java index 7da2be3..d3ff411 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java @@ -117,6 +117,8 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_002_036, "批量新增不允许指定为经理,请通过编辑成员调整"); // 批量移出(POST /project/project/{id}/members/batch/inactive)专用:同一请求内 memberId 重复 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 ========== 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_DIFFICULTY_INVALID = new ErrorCode(1_008_006_011, "完成难度不在字典范围内"); - // ========== 任务 / 工时附件 1_008_007_xxx ========== - ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个"); - ErrorCode PROJECT_TASK_ATTACHMENT_URL_INVALID = new ErrorCode(1_008_007_002, "附件地址非法,必须为 http/https URL 且长度不超过 1024"); - ErrorCode PROJECT_TASK_ATTACHMENT_NAME_INVALID = new ErrorCode(1_008_007_003, "附件文件名不合法(必填且长度不超过 255)"); - ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_007_004, "附件扩展名【{}】不在允许列表内"); - ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_007_005, "附件类型【{}】被禁止上传"); + // ========== 任务 / 工时附件 1_008_010_xxx(原 1_008_007 与下方项目需求段撞号,迁至独立号段;新增错误码域请从 1_008_011 起) ========== + ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_010_001, "附件数量不能超过 {} 个"); + 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_010_003, "附件文件名不合法(必填且长度不超过 255)"); + ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_010_004, "附件扩展名【{}】不在允许列表内"); + ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_010_005, "附件类型【{}】被禁止上传"); // ========== 项目需求 1_008_007_xxx ========== ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在"); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java index 1f4ebe6..8305131 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -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"; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectRequirementConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectRequirementConstants.java new file mode 100644 index 0000000..15a1030 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectRequirementConstants.java @@ -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"; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java index 0276e3d..28d3cfb 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectRequirementMapper.java @@ -92,4 +92,17 @@ public interface ProjectRequirementMapper extends BaseMapperX terminalStatusCodes) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectRequirementDO::getProjectId, projectId); + if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) { + queryWrapper.notIn(ProjectRequirementDO::getStatusCode, terminalStatusCodes); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java index 42a248c..6d8b574 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java @@ -143,6 +143,18 @@ public interface ProjectExecutionMapper extends BaseMapperX return Math.toIntExact(selectCount(queryWrapper)); } + /** + * 统计指定项目下处于非终态的执行数。用于项目 complete 前置校验(TD-015)。 + */ + default Integer countNonTerminalByProjectId(Long projectId, List terminalStatusCodes) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .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, 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 5fd2a3a..f997fd4 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 @@ -149,6 +149,18 @@ public interface ProjectTaskMapper extends BaseMapperX { return Math.toIntExact(selectCount(queryWrapper)); } + /** + * 统计指定项目下处于非终态的任务数。用于项目 complete 前置校验(TD-015,方案 B 进度维度)。 + */ + default Integer countNonTerminalByProjectId(Long projectId, List terminalStatusCodes) { + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getProjectId, projectId); + if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) { + queryWrapper.notIn(ProjectTaskDO::getStatusCode, terminalStatusCodes); + } + return Math.toIntExact(selectCount(queryWrapper)); + } + /** * 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java index 33c34e9..f66fc86 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java @@ -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) { 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 7662797..02f5aab 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 @@ -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 进度维度)/ 执行 / 需求;终态码全查 DB(terminal_flag),不硬编码。 + * 返回空串 = 满足完成条件。 + */ + private String collectCompletionGaps(Long projectId) { + List parts = new ArrayList<>(); + + List taskTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + Integer openTasks = projectTaskMapper.countNonTerminalByProjectId(projectId, taskTerminal); + if (openTasks != null && openTasks > 0) { + parts.add(openTasks + " 个任务"); + } + + List execTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + Integer openExecs = projectExecutionMapper.countNonTerminalByProjectId(projectId, execTerminal); + if (openExecs != null && openExecs > 0) { + parts.add(openExecs + " 个执行"); + } + + List 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()); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java index 1838773..ddb6434 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -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); diff --git a/rdms-project/rdms-project-boot/src/main/resources/sql/td015_project_completion_index.sql b/rdms-project/rdms-project-boot/src/main/resources/sql/td015_project_completion_index.sql new file mode 100644 index 0000000..e00c2e9 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/resources/sql/td015_project_completion_index.sql @@ -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),无需新增 diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java index 8904355..eef2de5 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -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(这里直接 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 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);