diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 63e53a1..72489a0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -73,7 +73,39 @@ "Bash(mv \"项目/rdms_biz_audit_log.sql\" sql/)", "Bash(mv \"项目/sql/\"*.sql sql/)", "Bash(mv \"项目/项目管理待确认项清单_V1.0.md\" domains/project/)", - "Bash(rmdir 项目)" + "Bash(rmdir 项目)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot compile -am -DskipTests 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\" -pl rdms-project/rdms-project-boot compile -am -DskipTests -f \"C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\pom.xml\" 2>&1 | Select-Object -Last 30)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test-compile -am 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\" -pl rdms-project/rdms-project-boot test -am \"-Dtest=ProjectTaskServiceImplTest,ProjectExecutionServiceImplTest\" 2>&1 | Select-Object -Last 80)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test -am \"-Dtest=ProjectTaskServiceImplTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" 2>&1 | Select-Object -Last 80)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test -am \"-Dtest=ProjectTaskServiceImplTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" 2>&1 | Select-Object -First 200)", + "PowerShell(Get-ChildItem \"C:\\\\code\\\\gitea\\\\rdms\\\\cn-rdms\\\\.claude\\\\worktrees\\\\agent-a0979555dc2fe9384\\\\rdms-project\\\\rdms-project-boot\\\\target\\\\surefire-reports\" | Where-Object { $_.Name -match \"Test\" } | ForEach-Object { $_.Name })", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot compile -am -DskipTests 2>&1 | Select-Object -Last 30)", + "Bash(/c/software/apache-maven-3.8.9/bin/mvn.cmd -pl rdms-project/rdms-project-boot test -am -Dtest=ProjectTaskServiceImplTest#changeTaskStatus_shouldUseTransitionAndWriteLogs -q)", + "Bash(/c/software/apache-maven-3.8.9/bin/mvn.cmd -pl rdms-project/rdms-project-boot test -Dtest=ProjectTaskServiceImplTest#changeTaskStatus_shouldUseTransitionAndWriteLogs -Dsurefire.failIfNoSpecifiedTests=false -q)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test \"-Dtest=ProjectTaskServiceImplTest#changeTaskStatus_shouldUseTransitionAndWriteLogs\" \"-Dsurefire.failIfNoSpecifiedTests=false\" -q 2>&1 | Select-Object -Last 60)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test-compile -am -q 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\" -pl rdms-project/rdms-project-boot test-compile -am -q 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\" -pl rdms-project/rdms-project-boot test-compile -am 2>&1 | Select-String \"BUILD SUCCESS|BUILD FAILURE|ERROR\" | Select-Object -Last 5)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot test -am \"-Dtest=ProjectTaskServiceImplTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" 2>&1 | Select-String \"Tests run|BUILD SUCCESS|BUILD FAILURE|FAILED|<<<\" | Select-Object -Last 30)", + "PowerShell([System.IO.Directory]::GetCurrentDirectory\\(\\))", + "Bash(Get-ChildItem -Directory -Name)", + "Bash(grep \"\\\\.java$\")", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests 2>&1 | Select-Object -Last 30)", + "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am clean compile -DskipTests 2>&1 | Select-Object -Last 35)", + "Bash(C:/Program Files/Java/jdk-17/bin/java.exe *)", + "Bash(export JAVA_HOME=\"C:/Program Files/Java/jdk-17\")", + "Bash(cd \"C:/code/gitea/rdms/cn-rdms\")", + "Bash(\"C:/software/apache-maven-3.8.9/bin/mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)", + "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests -q)", + "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests | Select-Object -Last 15)", + "PowerShell(java *)", + "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests -q 2>&1 | Select-Object -Last 30)", + "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; $out = & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests 2>&1; $code = $LASTEXITCODE; $out | Select-Object -Last 15; Write-Output \"EXIT=$code\")", + "Bash(set \"JAVA_HOME=C:\\\\Program Files\\\\Java\\\\jdk-17\")", + "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests -q)", + "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index e46a990..2060d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,16 @@ - SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。 - 缓存/日志/审计变更优先沿用既有机制,不要绕开登录上下文与审计字段填充。 +### 种子 SQL(纯 SQL INSERT 雪花 ID 表) + +`system_dict_type` / `system_dict_data` / `system_menu` 等历史表 id 由 MyBatis-Plus 雪花算法在 Java 层生成,DDL 无 `AUTO_INCREMENT`。纯 SQL 路径(字典种子、菜单种子等)写 INSERT 必须显式提供 id,否则 MySQL 报 `1364 - Field 'id' doesn't have a default value`。 + +- **id 取值**:`SET @new_id = (SELECT IFNULL(MAX(id), 0) + 1 FROM xxx_table);` 然后 INSERT `SELECT @new_id, ...`。雪花 ID 单调递增,`MAX+1` 落在已用区间之后,不会与未来 Java 生成的新雪花 ID 冲突。 +- **多条连续 INSERT**:每条 INSERT **前重新取** `MAX+1`——不要用 `base+1 / +2 / +3` 一次性算多个。配合 `NOT EXISTS` 守卫,部分已存在场景(半路重跑)才不会出现两条共用一个 id。 +- **collation 1267 陷阱**:仓库历史表 collation 不统一(如 `system_dict_data` 是 `utf8mb4_unicode_ci`,新表 `rdms_task_worklog` 是 `utf8mb4_0900_ai_ci`)。**不要**用 `SET @t = 'xxx'` 存字符串再 `WHERE col = @t`——用户变量自带连接级 collation,与列 collation 撞会报 `1267 Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_0900_ai_ci,IMPLICIT) for operation '='`。**对策**:直接展开成字面值,MySQL 字面值会按列 collation 隐式解析,不冲突。 + +样板参考:`docs/sql/rdms_task_worklog.sql:47-50`(菜单种子)+ `docs/sql/rdms_worklog_difficulty_seed.sql`(字典种子)。 + ## 注释与编码 - 关键字段/分支/约束/非直观实现补**简洁中文**注释;中文写入必须 UTF-8,不要用"改成英文"规避乱码。 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 c6e183d..541f577 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 @@ -123,6 +123,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行协办人已失效"); + // 保留:TD-013 解锁后业务路径已不会再触发,预留用于灰度回滚关闭关联能力 ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求"); ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行"); ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接"); @@ -135,9 +136,13 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_EXECUTION_ASSIGNEE_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行协办人"); ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行【{}】动作"); ErrorCode PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消"); - ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "仅初始态(待开始)的执行允许删除"); + ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "已完成的执行不允许删除"); ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致"); ErrorCode PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_003_021, "删除确认口令必须为 DELETE 或 删除"); + ErrorCode PROJECT_EXECUTION_PRIORITY_INVALID = new ErrorCode(1_008_003_022, "执行优先级不是有效字典值"); + ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_003_023, "关联的项目需求不存在或已删除"); + ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_BELONG_TO_PROJECT = new ErrorCode(1_008_003_024, "关联的项目需求不属于当前项目"); + ErrorCode PROJECT_EXECUTION_REQUIREMENT_TERMINAL = new ErrorCode(1_008_003_025, "项目需求已处于终态,不允许关联新执行"); // ========== 任务管理 1-008-004-000 ========== ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在"); @@ -151,9 +156,10 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_TASK_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_008, "当前任务状态不允许维护任务"); ErrorCode PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED = new ErrorCode(1_008_004_010, "父任务完成前,子任务必须全部完成或取消"); ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作"); - ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "仅初始态(待开始)的任务允许删除"); + ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "已完成的任务不允许删除"); ErrorCode PROJECT_TASK_DELETE_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致"); ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除"); + ErrorCode PROJECT_TASK_PRIORITY_INVALID = new ErrorCode(1_008_004_015, "任务优先级不是有效字典值"); ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零"); ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录"); @@ -175,6 +181,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID = new ErrorCode(1_008_006_007, "段起始日期不能晚于段结束日期"); ErrorCode PROJECT_TASK_WORKLOG_DATE_OVERLAP = new ErrorCode(1_008_006_008, "日期范围与该任务下您已有的工时记录重叠"); 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, "附件数量不能超过 {} 个"); @@ -201,6 +208,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_CHILDREN = new ErrorCode(1_008_007_015, "存在子模块,请先删除子模块"); ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_007_016, "模块下存在项目需求,请先删除需求"); ErrorCode PROJECT_REQUIREMENT_CHILD_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_017, "只有不存在子需求,或子需求都处于已取消和已拒绝状态时,父需求才允许取消"); + ErrorCode PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE = new ErrorCode(1_008_007_018, "该项目需求下存在承接执行,请先解绑或转移"); ErrorCode PROJECT_REQUIREMENT_SYNCED_FROM_PRODUCT_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_019, "\u7531\u4ea7\u54c1\u9700\u6c42\u6d41\u8f6c\u751f\u6210\u7684\u9879\u76ee\u9700\u6c42\u4e0d\u5141\u8bb8\u53d6\u6d88"); // ========== 个人事项 1_008_008_xxx ========== diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java index 6420b9f..97ef582 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java @@ -15,4 +15,14 @@ public interface ProjectDictTypeConstants { */ String EXECUTION_TYPE = "rdms_project_execution_type"; + /** + * 优先级(任务 / 执行 共用;P0=最高 ~ P3=最低)。 + */ + String REQ_PRIORITY = "rdms_req_priority"; + + /** + * 工时完成难度。 + */ + String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty"; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java index 920685e..699979e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectExecutionConstants.java @@ -51,6 +51,12 @@ public final class ProjectExecutionConstants { */ public static final String PERMISSION_DELETE = "project:execution:delete"; + /** + * 执行"已完成"状态码,对应 rdms_object_status_model 中 object_type='execution' 且 status_code='completed' 的状态。 + * 删除时拒绝主动删除(已完成的执行不允许删除)。 + */ + public static final String STATUS_COMPLETED = "completed"; + /** * 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。 * 校验时精确匹配(trim 后比对)。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java index 7c1bdc4..53e5377 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/ProjectExecutionController.java @@ -8,6 +8,7 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; @@ -92,8 +93,16 @@ public class ProjectExecutionController { return success(true); } + @GetMapping("/{executionId}/delete-precheck") + @Operation(summary = "执行删除预检:返回下挂任务数 + 是否需要重型确认") + public CommonResult precheckDeleteExecution( + @PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId) { + return success(projectExecutionService.precheckDeleteExecution(projectId, executionId)); + } + @DeleteMapping("/{executionId}") - @Operation(summary = "删除执行(仅初始态可删,三重确认)") + @Operation(summary = "删除执行(已完成态禁止,三重确认)") public CommonResult deleteExecution(@PathVariable("projectId") Long projectId, @PathVariable("executionId") Long executionId, @Valid @RequestBody ProjectExecutionDeleteReqVO reqVO) { diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionCreateReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionCreateReqVO.java index 464f6fb..c707ed4 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionCreateReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionCreateReqVO.java @@ -29,11 +29,17 @@ public class ProjectExecutionCreateReqVO { @Size(max = 32, message = "执行类型长度不能超过32个字符") private String executionType; + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "优先级不能为空") + @Size(max = 8, message = "优先级长度不能超过8个字符") + private String priority; + @Schema(description = "执行负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001") @NotNull(message = "执行负责人不能为空") private Long ownerId; - @Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001") + @Schema(description = "关联项目需求编号,可选;不传或传 null 表示无主执行。传入值必须属于本项目且需求非终态", example = "9001") private Long projectRequirementId; @Schema(description = "计划开始日期") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionDeletePrecheckRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionDeletePrecheckRespVO.java new file mode 100644 index 0000000..e38e5fa --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionDeletePrecheckRespVO.java @@ -0,0 +1,21 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 执行删除预检 Response VO。 + * 前端按 hasDependentData 走"简化路径 / 重型路径"分流: + * - false(无下挂数据) → 用户二次确认即可 + * - true → 弹层要求输入执行名称 + 删除口令 + 删除原因 + */ +@Schema(description = "管理后台 - 执行删除预检 Response VO") +@Data +public class ProjectExecutionDeletePrecheckRespVO { + + @Schema(description = "执行下任务总数(含子孙、含 completed)") + private Integer taskCount; + + @Schema(description = "是否存在下挂数据:taskCount > 0 视为 true") + private Boolean hasDependentData; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java index 6459b9a..a903d8b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionPageReqVO.java @@ -30,6 +30,10 @@ public class ProjectExecutionPageReqVO extends PageParam { @Size(max = 32, message = "执行状态编码长度不能超过32个字符") private String statusCode; + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) ~ 3=P3(最低)", example = "0") + @Size(max = 8) + private String priority; + @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] updateTime; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java index d187f16..7b29dfb 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionRespVO.java @@ -18,6 +18,10 @@ public class ProjectExecutionRespVO { private Long projectId; @Schema(description = "关联项目需求编号") private Long projectRequirementId; + @Schema(description = "关联项目需求名称(service 层批量回填,避免 N+1)", example = "前端联调-需求A") + private String projectRequirementName; + @Schema(description = "关联项目需求状态编码(service 层批量回填)", example = "implementing") + private String projectRequirementStatusCode; @Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调") private String executionName; @Schema(description = "执行类型", example = "feature") @@ -30,6 +34,10 @@ public class ProjectExecutionRespVO { private String statusCode; @Schema(description = "执行状态名称", example = "待开始") private String statusName; + @Schema(description = "优先级编码(字典 rdms_req_priority),0=P0(最高) ~ 3=P3(最低)", example = "0") + private String priority; + @Schema(description = "优先级名称(字典 label,后端不填、前端按字典 cache 自译)", example = "P0") + private String priorityName; @Schema(description = "是否终态", example = "false") private Boolean terminal; @Schema(description = "当前状态是否允许编辑", example = "true") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionUpdateReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionUpdateReqVO.java index 4d391ab..d2de5d9 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionUpdateReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/ProjectExecutionUpdateReqVO.java @@ -30,7 +30,13 @@ public class ProjectExecutionUpdateReqVO { @Size(max = 32, message = "执行类型长度不能超过32个字符") private String executionType; - @Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001") + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "优先级不能为空") + @Size(max = 8, message = "优先级长度不能超过8个字符") + private String priority; + + @Schema(description = "关联项目需求编号。PUT 全字段语义:传 null=解绑,传值=更新关联。传入值必须属于本项目且需求非终态", example = "9001") private Long projectRequirementId; @Schema(description = "计划开始日期") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java index 9f46211..69b7cae 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java @@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO; @@ -94,8 +95,17 @@ public class ProjectTaskController { return success(true); } + @GetMapping("/{taskId}/delete-precheck") + @Operation(summary = "任务删除预检:返回下挂子任务数 + 工作日志数 + 是否需要重型确认") + public CommonResult precheckDeleteTask( + @PathVariable("projectId") Long projectId, + @PathVariable("executionId") Long executionId, + @PathVariable("taskId") Long taskId) { + return success(projectTaskService.precheckDeleteTask(projectId, executionId, taskId)); + } + @DeleteMapping("/{taskId}") - @Operation(summary = "删除任务(仅初始态可删,三重确认 + 执行 owner 硬卡)") + @Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡)") public CommonResult deleteTask(@PathVariable("projectId") Long projectId, @PathVariable("executionId") Long executionId, @PathVariable("taskId") Long taskId, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java index fda33d1..8b4be43 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java @@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.controller.admin.project.task.vo; import com.njcn.rdms.framework.common.pojo.PageParam; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.format.annotation.DateTimeFormat; @@ -25,6 +26,10 @@ public class ProjectTaskBoardPageReqVO extends PageParam { example = "[\"pending\",\"active\"]") private String[] statusCode; + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) ~ 3=P3(最低)", example = "0") + @Size(max = 8) + private String priority; + @Schema(description = "关键词,匹配任务标题", example = "联调") private String keyword; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskDeletePrecheckRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskDeletePrecheckRespVO.java new file mode 100644 index 0000000..7517f9f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskDeletePrecheckRespVO.java @@ -0,0 +1,22 @@ +package com.njcn.rdms.module.project.controller.admin.project.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 任务删除预检 Response VO。 + * 协办人不计入 hasDependentData 判定,但级联删任务时仍会被一并软删。 + */ +@Schema(description = "管理后台 - 任务删除预检 Response VO") +@Data +public class ProjectTaskDeletePrecheckRespVO { + + @Schema(description = "直接子任务数(不含再下层子孙——前端展示用,判定仍按递归收集后实际删的数量)") + private Integer childTaskCount; + + @Schema(description = "工作日志条数") + private Integer worklogCount; + + @Schema(description = "是否存在下挂数据:childTaskCount + worklogCount > 0") + private Boolean hasDependentData; +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java index c84b2c1..07dc61e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskPageReqVO.java @@ -29,6 +29,10 @@ public class ProjectTaskPageReqVO extends PageParam { @Size(max = 32, message = "任务状态编码长度不能超过32个字符") private String statusCode; + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) ~ 3=P3(最低)", example = "0") + @Size(max = 8) + private String priority; + @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] updateTime; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java index 64f2602..d612eeb 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskRespVO.java @@ -21,6 +21,12 @@ public class ProjectTaskRespVO { private Long projectId; @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") private Long executionId; + @Schema(description = "所属执行关联的项目需求编号(service 层批量回填)") + private Long projectRequirementId; + @Schema(description = "项目需求名称(service 层批量回填,避免 N+1)", example = "前端联调-需求A") + private String projectRequirementName; + @Schema(description = "项目需求状态编码(service 层批量回填)", example = "implementing") + private String projectRequirementStatusCode; @Schema(description = "父任务编号") private Long parentTaskId; @Schema(description = "父任务负责人用户编号;一级任务为 null,子任务用于前端判断" @@ -41,6 +47,10 @@ public class ProjectTaskRespVO { private String statusCode; @Schema(description = "任务状态名称", example = "待开始") private String statusName; + @Schema(description = "优先级编码(字典 rdms_req_priority),0=P0(最高) ~ 3=P3(最低)", example = "0") + private String priority; + @Schema(description = "优先级名称(字典 label,后端不填、前端按字典 cache 自译)", example = "P0") + private String priorityName; @Schema(description = "是否终态", example = "false") private Boolean terminal; @Schema(description = "当前状态是否允许编辑", example = "true") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java index c8f4828..1874ae5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskSaveReqVO.java @@ -44,6 +44,12 @@ public class ProjectTaskSaveReqVO { @Size(max = 200000, message = "任务说明长度不能超过200000个字符") private String taskDesc; + @Schema(description = "优先级字典 rdms_req_priority 值,0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)", + requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "优先级不能为空") + @Size(max = 8, message = "优先级长度不能超过8个字符") + private String priority; + @Schema(description = "初始协办人用户编号列表;仅在创建任务时生效,编辑任务时静默忽略。" + "协办人通过独立接口管理,详见 /tasks/{id}/assignees") private List assigneeUserIds; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java index 4b86a4e..5787e64 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogPageReqVO.java @@ -21,4 +21,8 @@ public class TaskWorklogPageReqVO extends PageParam { @Schema(description = "查询区间截止日期(含),按段相交过滤(record.startDate <= endDate)") private LocalDate endDate; + @Schema(description = "完成难度字典 rdms_worklog_difficulty 值;不传表示全部", + example = "3") + private String difficulty; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java index 1854cec..f95c17b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogRespVO.java @@ -37,6 +37,12 @@ public class TaskWorklogRespVO { @Schema(description = "本次填报进度(0~100)", example = "60.00") private BigDecimal progressRate; + @Schema(description = "完成难度编码(字典 rdms_worklog_difficulty)", example = "2") + private String difficulty; + + @Schema(description = "完成难度名称(字典 label,后端不填、前端按字典 cache 自译)", example = "一般") + private String difficultyName; + @Schema(description = "工作内容描述") private String workContent; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java index 9cf2fbe..d002d67 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/worklog/TaskWorklogSaveReqVO.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @@ -46,6 +47,12 @@ public class TaskWorklogSaveReqVO { @DecimalMax(value = "100.00", message = "本次填报进度不能大于 100") private BigDecimal progressRate; + @Schema(description = "完成难度字典 rdms_worklog_difficulty 值,1=简单 / 2=一般 / 3=困难 / 4=超难", + requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotBlank(message = "完成难度不能为空") + @Size(max = 8, message = "完成难度长度不能超过 8 个字符") + private String difficulty; + @Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试") @Size(max = 2000, message = "工作内容长度不能超过 2000 个字符") private String workContent; @@ -53,7 +60,4 @@ public class TaskWorklogSaveReqVO { @Schema(description = "附件列表;规则与限制详见 AttachmentValidator(数量上限、扩展名白/黑名单、URL 协议)") @Valid private List attachments; - - @Schema(description = "任务难度", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") - private String difficulty; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/requirement/ProjectRequirementRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/requirement/ProjectRequirementRespVO.java index 600fce7..b6318ae 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/requirement/ProjectRequirementRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/requirement/ProjectRequirementRespVO.java @@ -4,6 +4,7 @@ import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -96,4 +97,11 @@ public class ProjectRequirementRespVO { @Schema(description = "是否为终态", example = "false") private Boolean terminal; + @Schema(description = "需求进度(TD-016 读时聚合,service 层批量计算)。" + + "公式:AVG(该需求自己承接的执行进度 ∪ 直接子需求进度)," + + "排除 rdms_object_status_model.progress_excluded_flag=1 的执行状态(当前为 cancelled);" + + "无任何执行且无子需求时返回 0.00。两位小数,HALF_UP。", + example = "0.65") + private BigDecimal progressRate; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java index b0d78b3..246846d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/execution/ProjectExecutionDO.java @@ -1,5 +1,7 @@ package com.njcn.rdms.module.project.dal.dataobject.project.execution; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; @@ -27,8 +29,9 @@ public class ProjectExecutionDO extends BaseDO { */ private Long projectId; /** - * 关联项目需求编号,第一阶段仅保留字段 + * 关联项目需求编号。PUT 全字段语义:null=解绑(参见 CLAUDE.md 接口语义章节)。 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long projectRequirementId; /** * 执行名称 @@ -46,6 +49,10 @@ public class ProjectExecutionDO extends BaseDO { * 执行状态编码 */ private String statusCode; + /** + * 优先级字典 rdms_req_priority,0=P0(最高) ~ 3=P3(最低) + */ + private String priority; /** * 计划开始日期 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java index b70bf86..e71de5a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/ProjectTaskDO.java @@ -54,6 +54,10 @@ public class ProjectTaskDO extends BaseDO { * 任务状态编码 */ private String statusCode; + /** + * 优先级字典 rdms_req_priority,0=P0(最高) ~ 3=P3(最低) + */ + private String priority; /** * 任务进度 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java index 40c6998..7a1bc7c 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/project/task/TaskWorklogDO.java @@ -58,6 +58,11 @@ public class TaskWorklogDO extends BaseDO { * */ private BigDecimal progressRate; + /** + * 完成难度字典 {@code rdms_worklog_difficulty}: + * "1" 简单 / "2" 一般 / "3" 困难 / "4" 超难,数值大=越难。必填,DDL 默认 "2"。 + */ + private String difficulty; /** * 工作内容描述。允许在 update 时传 null 清空(updateStrategy=ALWAYS 跳过全局 NOT_NULL 策略, * 始终参与 SQL 拼接,包括 null)。调用方约定:update 必须全字段回传,不能用 null 表示"未改动"。 @@ -71,5 +76,4 @@ public class TaskWorklogDO extends BaseDO { @TableField(typeHandler = JacksonTypeHandler.class) private List attachments; - private String difficulty; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java index 3fbb00a..34101ea 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ExecutionAssigneeMapper.java @@ -67,4 +67,15 @@ public interface ExecutionAssigneeMapper extends BaseMapperX selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId, @Param("userId") Long userId); + /** + * 按 execution_id 批量软删执行协办(含已 removed 的历史段)。 + */ + default int deleteByExecutionId(Long executionId) { + if (executionId == null) { + return 0; + } + return delete(new LambdaQueryWrapperX() + .eq(ExecutionAssigneeDO::getExecutionId, executionId)); + } + } 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 d5afcb9..4331a05 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 @@ -9,9 +9,13 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.springframework.util.StringUtils; +import java.util.Collection; import java.util.List; +import java.util.Map; @Mapper public interface ProjectExecutionMapper extends BaseMapperX { @@ -35,14 +39,16 @@ public interface ProjectExecutionMapper extends BaseMapperX if (!scope.seesAll() && scope.executionIds().isEmpty()) { return PageResult.empty(); } - LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() - .eq(ProjectExecutionDO::getProjectId, projectId) - .eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()) - .eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId()) - .eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode()) - .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()) - .orderByDesc(BaseDO::getCreateTime) - .orderByDesc(ProjectExecutionDO::getId); + LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX<>(); + queryWrapper.eq(ProjectExecutionDO::getProjectId, projectId); + queryWrapper.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()); + queryWrapper.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId()); + queryWrapper.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode()); + queryWrapper.eqIfPresent(ProjectExecutionDO::getPriority, reqVO.getPriority()); + queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); + queryWrapper.orderByAsc(ProjectExecutionDO::getPriority); + queryWrapper.orderByDesc(BaseDO::getCreateTime); + queryWrapper.orderByDesc(ProjectExecutionDO::getId); if (StringUtils.hasText(reqVO.getKeyword())) { queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword())); } @@ -106,6 +112,40 @@ public interface ProjectExecutionMapper extends BaseMapperX return Math.toIntExact(selectCount(queryWrapper)); } + /** + * TD-016:按 projectRequirementIds 批量聚合承接执行的平均进度,避免列表 N+1。 + *

口径:仅统计 deleted = 0 + status_code NOT IN(excludedStatusCodes) 的执行; + * excludedStatusCodes 由 rdms_object_status_model.progress_excluded_flag=1 动态读取,不在代码硬编码。 + * 返回行格式:{ projectRequirementId, progressRate }(仅出现的 requirementId 才有行,无承接执行的不返回)。 + */ + @Select(""" + + """) + List> selectAvgProgressGroupByProjectRequirementIds( + @Param("projectRequirementIds") Collection projectRequirementIds, + @Param("excludedStatusCodes") Collection excludedStatusCodes); + + /** + * 统计指定项目需求下的有效(未软删)承接执行数。 + * 用于项目需求删除前的"先解绑/转移承接执行"前置校验。 + */ + default long countActiveByProjectRequirementId(Long projectRequirementId) { + return selectCount(new LambdaQueryWrapperX() + .eq(ProjectExecutionDO::getProjectRequirementId, projectRequirementId)); + } + default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { ProjectExecutionDO update = new ProjectExecutionDO(); update.setStatusCode(toStatus); 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 7d63bbb..4fe0285 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 @@ -43,8 +43,10 @@ public interface ProjectTaskMapper extends BaseMapperX { queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId()); queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId()); queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode()); + queryWrapper.eqIfPresent(ProjectTaskDO::getPriority, reqVO.getPriority()); queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId); + queryWrapper.orderByAsc(ProjectTaskDO::getPriority); queryWrapper.orderByDesc(BaseDO::getCreateTime); queryWrapper.orderByDesc(ProjectTaskDO::getId); if (StringUtils.hasText(reqVO.getKeyword())) { @@ -319,4 +321,62 @@ public interface ProjectTaskMapper extends BaseMapperX { return Math.toIntExact(selectCount(queryWrapper)); } + /** + * 收集执行下的所有任务 id(含子孙——子孙必然同 execution_id,所以一把抓即可)。 + * 用于"删除执行"时的级联软删。 + */ + default List selectIdsByExecutionId(Long executionId) { + if (executionId == null) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getExecutionId, executionId) + .select(ProjectTaskDO::getId)) + .stream() + .map(ProjectTaskDO::getId) + .toList(); + } + + /** + * 从给定任务出发,递归向下收集自身 + 所有子孙任务 id(递归 CTE)。 + * 用于"删除任务"时的级联软删。复用与 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 同款 CTE 模式。 + * + * 任务表已逻辑删除的行不参与递归。 + */ + @Select(""" + WITH RECURSIVE descendants (id) AS ( + SELECT id FROM rdms_task + WHERE deleted = b'0' + AND id = #{taskId} + UNION ALL + SELECT t.id FROM rdms_task t + JOIN descendants d ON t.parent_task_id = d.id + WHERE t.deleted = b'0' + ) + SELECT id FROM descendants + """) + List selectSelfAndDescendantIds(@Param("taskId") Long taskId); + + /** + * 按 id 集合批量软删任务。@TableLogic 自动落 deleted=1。 + */ + default int deleteByIdIn(Collection ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + return delete(new LambdaQueryWrapperX() + .in(ProjectTaskDO::getId, ids)); + } + + /** + * 执行下任务计数(用于删除预检)。 + */ + default int countByExecutionId(Long executionId) { + if (executionId == null) { + return 0; + } + return Math.toIntExact(selectCount(new LambdaQueryWrapperX() + .eq(ProjectTaskDO::getExecutionId, executionId))); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java index 0cfc1ce..111807c 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskAssigneeMapper.java @@ -94,4 +94,16 @@ public interface TaskAssigneeMapper extends BaseMapperX { .eq(TaskAssigneeDO::getTaskId, taskId)); } + /** + * 按 task_id 集合批量软删任务协办(含已 removed 的历史段,统一标 deleted=1)。 + * 协办人不参与"是否有下挂数据"判定,但级联删任务时仍要一并清理。 + */ + default int deleteByTaskIdIn(Collection taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return 0; + } + return delete(new LambdaQueryWrapperX() + .in(TaskAssigneeDO::getTaskId, taskIds)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java index f3badfc..4b69791 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/TaskWorklogMapper.java @@ -35,7 +35,8 @@ public interface TaskWorklogMapper extends BaseMapperX { default PageResult selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) { LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() .eq(TaskWorklogDO::getTaskId, taskId) - .eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId()); + .eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId()) + .eqIfPresent(TaskWorklogDO::getDifficulty, reqVO.getDifficulty()); if (reqVO.getEndDate() != null) { queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate()); } @@ -165,4 +166,26 @@ public interface TaskWorklogMapper extends BaseMapperX { return selectCount(queryWrapper) > 0; } + /** + * 按 task_id 集合批量软删工作日志。用于级联删除任务时一并清理。 + */ + default int deleteByTaskIdIn(Collection taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return 0; + } + return delete(new LambdaQueryWrapperX() + .in(TaskWorklogDO::getTaskId, taskIds)); + } + + /** + * 单任务工作日志条数(用于删除预检)。 + */ + default int countByTaskId(Long taskId) { + if (taskId == null) { + return 0; + } + return Math.toIntExact(selectCount(new LambdaQueryWrapperX() + .eq(TaskWorklogDO::getTaskId, taskId))); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementService.java index 524eb3b..0619714 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementService.java @@ -113,4 +113,12 @@ public interface ProjectRequirementService { */ List getRequirementTerminalStatusDict(); + /** + * 校验项目需求可被执行关联。 + *

用于执行模块在 create / update 时确认传入的 projectRequirementId 合法。 + * 校验:存在且未软删 + 属于本项目 + 非终态。任一不满足抛 ServiceException。 + * 传入 requirementId 为 null 时直接放行(执行侧已确定 projectRequirementId 可选)。 + */ + void validateUsableForExecution(Long projectId, Long projectRequirementId); + } 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 3b7702b..b2f51af 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 @@ -6,6 +6,7 @@ import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils; 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.controller.admin.project.vo.requirement.ProjectRequirementAllowedTransitionBatchRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO; @@ -35,6 +36,7 @@ import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLo import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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; @@ -46,6 +48,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -120,6 +124,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService private ObjectStatusModelMapper statusModelMapper; @Resource private AttachmentFileIdResolver attachmentFileIdResolver; + @Resource + private ProjectExecutionMapper projectExecutionMapper; @Override @Transactional(rollbackFor = Exception.class) @@ -199,7 +205,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService public ProjectRequirementRespVO getRequirement(Long id, Long projectId) { ProjectRequirementDO requirement = validateRequirementExists(id); validateRequirementBelongsToProject(requirement, projectId); - return buildRequirementRespVO(requirement); + ProjectRequirementRespVO respVO = buildRequirementRespVO(requirement); + fillRequirementProgress(projectId, List.of(respVO)); + return respVO; } @Override @@ -220,6 +228,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService List list = pageResult.getList().stream() .map(requirement -> buildRequirementRespVO(requirement, statusModelMap)) .collect(Collectors.toList()); + fillRequirementProgress(pageReqVO.getProjectId(), list); return new PageResult<>(list, pageResult.getTotal()); } @@ -274,6 +283,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService List list = pagedRootRequirements.stream() .map(requirement -> buildRequirementRespVOWithPathChildren(requirement, pathNodeIds, childrenMap, statusModelMap)) .collect(Collectors.toList()); + fillRequirementProgress(projectId, list); return new PageResult<>(list, (long) total); } @@ -336,6 +346,10 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) { throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_DELETE); } + // TD-013 口径 Q4=A:项目需求下有承接执行时禁止删除,需先解绑或转移 + if (projectExecutionMapper.countActiveByProjectRequirementId(id) > 0) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE); + } int deleteCount = requirementMapper.deleteByIdAndStatus(id, fromStatus); if (deleteCount != 1) { @@ -345,6 +359,23 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService buildRequirementFieldChanges(requirement, null), null); } + @Override + public void validateUsableForExecution(Long projectId, Long projectRequirementId) { + if (projectRequirementId == null) { + return; + } + ProjectRequirementDO requirement = requirementMapper.selectById(projectRequirementId); + if (requirement == null) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_EXISTS); + } + if (!Objects.equals(requirement.getProjectId(), projectId)) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_BELONG_TO_PROJECT); + } + if (TERMINAL_STATUSES.contains(requirement.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL); + } + } + @Override @Transactional(rollbackFor = Exception.class) @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", @@ -1035,6 +1066,124 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService return respVO; } + /** + * TD-016:把项目需求 RespVO 列表上的 progressRate 字段批量回填。 + *

口径:按 projectId 一次性算出本项目下所有需求的进度 map,再递归扫 RespVO 树(含 children)应用。 + * 公式:R.progressRate = AVG(R 自己挂的执行进度 ∪ R 的直接子需求进度),排除进度排除字典命中的执行状态; + * 当 pool 为空(无承接执行且无子需求)时返回 0.00。两位小数 HALF_UP。 + */ + private void fillRequirementProgress(Long projectId, Collection respVOList) { + if (respVOList == null || respVOList.isEmpty() || projectId == null) { + return; + } + Map progressMap = computeRequirementProgressMapByProjectId(projectId); + applyProgressRecursive(respVOList, progressMap); + } + + private void applyProgressRecursive(Collection list, Map progressMap) { + BigDecimal defaultValue = normalizeProgress(BigDecimal.ZERO); + for (ProjectRequirementRespVO vo : list) { + vo.setProgressRate(progressMap.getOrDefault(vo.getId(), defaultValue)); + if (vo.getChildren() != null && !vo.getChildren().isEmpty()) { + applyProgressRecursive(vo.getChildren(), progressMap); + } + } + } + + /** + * 按 projectId 算所有需求进度,返回 Map<requirementId, progressRate>。 + * 公式与 fillRequirementProgress 对齐:自下而上 DFS,pool = 自己挂的执行平均 ∪ 直接子需求进度。 + */ + @VisibleForTesting + Map computeRequirementProgressMapByProjectId(Long projectId) { + List allRequirements = requirementMapper.selectList( + new LambdaQueryWrapperX() + .eq(ProjectRequirementDO::getProjectId, projectId)); + if (allRequirements.isEmpty()) { + return Collections.emptyMap(); + } + Set requirementIds = allRequirements.stream() + .map(ProjectRequirementDO::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + // 一次 GROUP BY 拉到所有需求"自己挂的执行平均进度" + List excludedStatusCodes = loadProgressExcludedExecutionStatusCodes(); + Map ownAvgMap = new HashMap<>(); + List> rows = projectExecutionMapper + .selectAvgProgressGroupByProjectRequirementIds(requirementIds, excludedStatusCodes); + if (rows != null) { + for (Map row : rows) { + Object idObj = row.get("projectRequirementId"); + Object progressObj = row.get("progressRate"); + if (idObj == null || progressObj == null) { + continue; + } + Long reqId = ((Number) idObj).longValue(); + BigDecimal avg = progressObj instanceof BigDecimal + ? (BigDecimal) progressObj + : new BigDecimal(progressObj.toString()); + ownAvgMap.put(reqId, avg); + } + } + + // 按 parentId 建子需求索引(顶级父 id = 0) + Map> childrenIndex = allRequirements.stream() + .collect(Collectors.groupingBy(r -> r.getParentId() == null ? 0L : r.getParentId())); + + // DFS 自下而上递归 + Map result = new HashMap<>(); + for (ProjectRequirementDO r : allRequirements) { + if (!result.containsKey(r.getId())) { + computeProgressDfs(r, childrenIndex, ownAvgMap, result); + } + } + return result; + } + + private BigDecimal computeProgressDfs(ProjectRequirementDO r, + Map> childrenIndex, + Map ownAvgMap, + Map result) { + if (result.containsKey(r.getId())) { + return result.get(r.getId()); + } + List pool = new ArrayList<>(); + BigDecimal ownAvg = ownAvgMap.get(r.getId()); + if (ownAvg != null) { + pool.add(ownAvg); + } + List children = childrenIndex.getOrDefault(r.getId(), List.of()); + for (ProjectRequirementDO child : children) { + pool.add(computeProgressDfs(child, childrenIndex, ownAvgMap, result)); + } + BigDecimal value; + if (pool.isEmpty()) { + value = normalizeProgress(BigDecimal.ZERO); + } else { + BigDecimal sum = pool.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + value = sum.divide(BigDecimal.valueOf(pool.size()), 2, RoundingMode.HALF_UP); + } + result.put(r.getId(), value); + return value; + } + + /** + * 从 rdms_object_status_model 字典动态读取执行的"进度排除状态"列表, + * 当前命中 cancelled;任何时候运维通过 progress_excluded_flag 增减,service 层无需重新部署。 + */ + private List loadProgressExcludedExecutionStatusCodes() { + List codes = statusModelMapper + .selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + return codes == null ? Collections.emptyList() : codes; + } + + private BigDecimal normalizeProgress(BigDecimal value) { + if (value == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return value.setScale(2, RoundingMode.HALF_UP); + } + private List buildModuleTree(List modules, Long parentId) { return modules.stream() .filter(module -> Objects.equals(module.getParentId(), parentId)) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java index 19eecc5..b452d8e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java @@ -125,6 +125,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService innerReq.setKeyword(reqVO.getKeyword()); innerReq.setParentTaskId(reqVO.getParentTaskId()); innerReq.setOwnerId(reqVO.getOwnerId()); + innerReq.setPriority(reqVO.getPriority()); innerReq.setUpdateTime(reqVO.getUpdateTime()); innerReq.setStatusCode(statusCode); return innerReq; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java index 1977774..9777319 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java @@ -5,6 +5,7 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; @@ -40,12 +41,26 @@ public interface ProjectExecutionService { void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); /** - * 删除执行(软删 + CAS 状态校验 + 三重确认)。 - *

仅初始态 pending 可删;权限码 project:execution:delete 推荐挂"项目负责人"角色。 - *

不级联软删 rdms_execution_assignee / rdms_task(与项目侧 deleteProject 风格一致,下挂记录通过 deleted=0 自然不可见)。 + * 删除执行(软删 + 状态校验 + 三重确认 + 级联软删)。 + *

删除规则:状态非 completed(已完成)即可删除,pending / active / paused / cancelled 全部允许; + * 仅 completed 拒绝主动删除(已完成数据需保留作为审计与统计依据)。 + *

三重确认:reqVO 中 executionName(执行名称)、confirmText(删除口令 DELETE 或 删除)、reason(删除原因)三者均必填, + * 任一缺失或不匹配直接拒绝;reason 文本入审计日志。 + *

级联范围:同步软删该执行下全部任务(含子孙任务)、任务工作日志、任务协办、执行协办。 + *

软删机制:所有 mapper.deleteXxx 走 @TableLogic 自动转 UPDATE deleted=1,下挂数据通过 deleted=0 过滤自然不可见。 + *

权限:@CheckObjectPermission(objectType=PROJECT, permission=PERMISSION_DELETE) 对象域权限码,推荐挂"项目负责人"角色。 + *

审计:写 EXECUTION_ACTION_DELETE 一条,reason 入 audit_reason 字段。 */ void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO); + /** + * 删除执行前的下挂数据预检。 + *

返回执行下任务总数(含已软删之外的全部活跃任务)与 hasDependentData(是否存在下挂)标记。 + *

前端依据该结果分流:无下挂走简化确认路径(仅口令 + 原因),有下挂走重型确认路径(额外提示级联影响范围)。 + *

本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 {@link #deleteExecution} 负责。 + */ + ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId); + void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO); /** 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 fbb17db..46d4c33 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 @@ -13,12 +13,14 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO; @@ -28,15 +30,19 @@ 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.constant.ProjectTaskConstants; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; 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; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; +import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.project.service.project.ProjectService; import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver; @@ -104,8 +110,16 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { @Resource private ProjectTaskMapper projectTaskMapper; @Resource + private TaskWorklogMapper taskWorklogMapper; + @Resource + private TaskAssigneeMapper taskAssigneeMapper; + @Resource private ProjectService projectService; @Resource + private ProjectRequirementService projectRequirementService; + @Resource + private ProjectRequirementMapper projectRequirementMapper; + @Resource private VisibilityScopeResolver visibilityScopeResolver; /** * 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。 @@ -121,23 +135,25 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { permission = ProjectExecutionConstants.PERMISSION_CREATE) public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) { validateEditableProject(projectId); - validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); + projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId()); String executionName = normalizeRequiredName(reqVO.getExecutionName()); validateExecutionNameUnique(projectId, null, executionName); validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId()); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); validateExecutionType(executionType); + validatePriority(reqVO.getPriority()); Set assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds()); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); ProjectExecutionDO execution = new ProjectExecutionDO(); execution.setProjectId(projectId); - execution.setProjectRequirementId(null); + execution.setProjectRequirementId(reqVO.getProjectRequirementId()); execution.setExecutionName(executionName); execution.setExecutionType(executionType); execution.setOwnerId(reqVO.getOwnerId()); String initialStatusCode = getInitialExecutionStatusCode(); execution.setStatusCode(initialStatusCode); + execution.setPriority(reqVO.getPriority()); execution.setPlannedStartDate(reqVO.getPlannedStartDate()); execution.setPlannedEndDate(reqVO.getPlannedEndDate()); execution.setActualStartDate(null); @@ -163,18 +179,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { validateEditableProject(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId()); validateExecutionAllowEdit(execution); - validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); + projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId()); String executionName = normalizeRequiredName(reqVO.getExecutionName()); validateExecutionNameUnique(projectId, execution.getId(), executionName); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); validateExecutionType(executionType); + validatePriority(reqVO.getPriority()); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); // 不再处理 ownerId:换负责人必须走 /change-owner 端点(spec §6.3 契约收口) ProjectExecutionDO before = cloneExecution(execution); execution.setExecutionName(executionName); execution.setExecutionType(executionType); - execution.setProjectRequirementId(null); + execution.setPriority(reqVO.getPriority()); + execution.setProjectRequirementId(reqVO.getProjectRequirementId()); execution.setPlannedStartDate(reqVO.getPlannedStartDate()); execution.setPlannedEndDate(reqVO.getPlannedEndDate()); execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc())); @@ -214,6 +232,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId); applyLifecycle(respVO, rootTasksAllCompleted); respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId())); + fillProjectRequirementInfo(List.of(respVO)); return respVO; } @@ -226,6 +245,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { return voPageResult; } fillExecutionProgress(projectId, list); + fillProjectRequirementInfo(list); // 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 false,complete 按钮不下发。 Map rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list); // 批量补负责人昵称,避免 N+1 @@ -315,26 +335,48 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", permission = ProjectExecutionConstants.PERMISSION_DELETE) public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) { - validateEditableProject(projectId); + validateProjectExists(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); validateDeleteConfirmText(reqVO.getConfirmText()); if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH); } - // 仅初始态可删(与项目侧 deleteProject 同款规则) - String initialStatusCode = getInitialExecutionStatusCode(); - String fromStatus = execution.getStatusCode(); - if (!Objects.equals(fromStatus, initialStatusCode)) { + // 删除路径不复用 validateEditableProject / 状态 allow_edit:已完成执行用专门的硬卡拦截, + // 其它非 completed 状态(pending / active / paused / cancelled)允许删除。 + if (Objects.equals(execution.getStatusCode(), ProjectExecutionConstants.STATUS_COMPLETED)) { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE); } String reason = reqVO.getReason().trim(); - int deleteCount = projectExecutionMapper.deleteByIdAndStatus(executionId, fromStatus); + String fromStatus = execution.getStatusCode(); + + // 级联软删:先清子级(worklog / task assignee / task),再清同级协办,最后软删执行本身 + List taskIds = projectTaskMapper.selectIdsByExecutionId(executionId); + if (!taskIds.isEmpty()) { + taskWorklogMapper.deleteByTaskIdIn(taskIds); + taskAssigneeMapper.deleteByTaskIdIn(taskIds); + projectTaskMapper.deleteByIdIn(taskIds); + } + executionAssigneeMapper.deleteByExecutionId(executionId); + int deleteCount = projectExecutionMapper.deleteById(executionId); if (deleteCount != 1) { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED); } writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason); } + @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_DELETE) + public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) { + validateProjectExists(projectId); + validateExecutionExists(projectId, executionId); + int taskCount = projectTaskMapper.countByExecutionId(executionId); + ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO(); + respVO.setTaskCount(taskCount); + respVO.setHasDependentData(taskCount > 0); + return respVO; + } + private void validateDeleteConfirmText(String confirmText) { String normalizedConfirmText = normalizeNullableText(confirmText); if (normalizedConfirmText == null @@ -420,13 +462,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { return execution; } - @VisibleForTesting - void validateRequirementIdPhaseOne(Long projectRequirementId) { - if (projectRequirementId != null) { - throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY); - } - } - @VisibleForTesting void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) { // 多角色支持:user 在项目内有任意 ACTIVE 角色即视为项目成员 @@ -497,6 +532,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID); } + @VisibleForTesting + void validatePriority(String priority) { + try { + Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.REQ_PRIORITY, + java.util.List.of(priority.trim())).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID); + } + @VisibleForTesting void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { if (startDate == null || endDate == null) { @@ -627,6 +676,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { valueOf(after, ProjectExecutionDO::getOwnerId)); appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode), valueOf(after, ProjectExecutionDO::getStatusCode)); + appendFieldChange(fieldChanges, "priority", + valueOf(before, ProjectExecutionDO::getPriority), + valueOf(after, ProjectExecutionDO::getPriority)); appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate), valueOf(after, ProjectExecutionDO::getPlannedStartDate)); appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectExecutionDO::getPlannedEndDate), @@ -746,6 +798,33 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null)))); } + /** + * 批量回填执行 RespVO 上的项目需求名称 / 状态编码(TD-013)。 + * 一次性 selectBatchIds 拉取所有出现过的 projectRequirementId,避免列表场景 N+1。 + */ + private void fillProjectRequirementInfo(Collection list) { + if (list == null || list.isEmpty()) { + return; + } + Set requirementIds = list.stream() + .map(ProjectExecutionRespVO::getProjectRequirementId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (requirementIds.isEmpty()) { + return; + } + Map map = projectRequirementMapper.selectBatchIds(requirementIds) + .stream() + .collect(Collectors.toMap(ProjectRequirementDO::getId, Function.identity())); + list.forEach(vo -> { + ProjectRequirementDO requirement = map.get(vo.getProjectRequirementId()); + if (requirement != null) { + vo.setProjectRequirementName(requirement.getTitle()); + vo.setProjectRequirementStatusCode(requirement.getStatusCode()); + } + }); + } + /** * 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。 * 未命中的 executionId(执行下无一级任务)不入 map,由调用方 normalizeProgress 兜底为 0.00。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java index dd990fd..6acd474 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java @@ -1,6 +1,7 @@ package com.njcn.rdms.module.project.service.project.task; import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; @@ -42,12 +43,27 @@ public interface ProjectTaskService { void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); /** - * 删除任务(软删 + CAS 状态校验 + 三重确认 + 执行 owner 字段硬卡)。 - *

仅初始态可删;权限码 project:task:delete 作菜单入口可见度;实际拦截以 execution.ownerId == currentUserId 为准(spec §5.1 上级硬卡范式)。 - *

不级联软删任务下挂的工时 / 协办人 / 子任务(通过 deleted=0 自然不可见)。 + * 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。 + *

删除规则:非 completed 即可删(pending / active / paused / cancelled 全允许); + * 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截。 + *

上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete})"删; + * 子任务由"父任务负责人 OR 项目负责人(权限码)"删。 + *

三重确认:{@code taskName} / {@code confirmText}(DELETE 或 删除)/ {@code reason} 全部必填, + * 任意一项缺失或不匹配即拒。 + *

级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办; + * 执行 / 项目需求 progressRate 是读时聚合,无需主动刷新。 + *

父进度回算:删完后若有 parent_task_id,触发 {@code recalcParentProgressFrom} 把祖先链路进度重算。 + *

审计:写一条 {@code TASK_ACTION_DELETE} 审计记录,reason 入审计上下文。 */ void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO); + /** + * 任务删除前的下挂数据预检:返回子任务数 + 工作日志数 + hasDependentData 标记。 + *

前端依据该结果分流:无下挂走简化确认路径,有下挂走重型确认路径。 + *

本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 deleteTask 负责。 + */ + ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId); + /** * 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。 * 由工时模块在 owner 维度的 worklog create/update/delete 后调用。 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 06b0017..13696f7 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 @@ -10,6 +10,7 @@ import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants; import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; @@ -17,6 +18,7 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO; @@ -26,10 +28,12 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO; import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; @@ -44,6 +48,9 @@ import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver; import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService; +import com.google.common.annotations.VisibleForTesting; +import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; +import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; @@ -83,12 +90,16 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { @Resource private ProjectExecutionMapper projectExecutionMapper; @Resource + private ProjectRequirementMapper projectRequirementMapper; + @Resource private ExecutionAssigneeMapper executionAssigneeMapper; @Resource private ProjectTaskMapper projectTaskMapper; @Resource private TaskWorklogMapper taskWorklogMapper; @Resource + private TaskAssigneeMapper taskAssigneeMapper; + @Resource private ProjectTaskStatusLogMapper projectTaskStatusLogMapper; @Resource private BizAuditLogMapper bizAuditLogMapper; @@ -114,6 +125,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { private ProjectObjectAuthorizationService projectObjectAuthorizationService; @Resource private VisibilityScopeResolver visibilityScopeResolver; + @Resource + private DictDataApi dictDataApi; @Override @Transactional(rollbackFor = Exception.class) @@ -131,6 +144,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); // 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录 validateLeafToParentSplit(parentTask); + validatePriority(reqVO.getPriority()); AttachmentValidator.validate(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments()); @@ -142,6 +156,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空")); task.setOwnerId(ownerId); task.setStatusCode(getInitialTaskStatusCode()); + task.setPriority(reqVO.getPriority()); // 任务进度统一由 worklog 驱动;新建任务强制为 0 task.setProgressRate(BigDecimal.ZERO); task.setPlannedStartDate(reqVO.getPlannedStartDate()); @@ -196,6 +211,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { if (!Objects.equals(oldParentId, newParentId)) { validateLeafToParentSplit(parentTask); } + validatePriority(reqVO.getPriority()); AttachmentValidator.validate(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments()); // 任务进度由 worklog 驱动(owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参 @@ -205,6 +221,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空")); task.setOwnerId(ownerId); + task.setPriority(reqVO.getPriority()); task.setPlannedStartDate(reqVO.getPlannedStartDate()); task.setPlannedEndDate(reqVO.getPlannedEndDate()); // 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护 @@ -229,35 +246,86 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { @Override @Transactional(rollbackFor = Exception.class) public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) { - validateEditableProject(projectId); + validateProjectExists(projectId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); - validateExecutionAllowEdit(execution); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); // 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删 - Long upperOwnerId = task.getParentTaskId() == null - ? execution.getOwnerId() - : projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId(); - projectObjectAuthorizationService.checkOwnerOrProjectPermission( - projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE); + requireDeletePermissionOnTask(projectId, task, execution); validateDeleteConfirmText(reqVO.getConfirmText()); if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) { throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH); } - // 仅初始态可删 - String fromStatus = task.getStatusCode(); - ObjectStatusModelDO statusModel = objectStatusModelMapper - .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus); - if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) { + // 删除路径不复用 validateEditableProject / validateExecutionAllowEdit:已完成任务用专门的硬卡拦截, + // 其它非 completed 状态(pending / active / paused / cancelled)允许删除。 + if (Objects.equals(task.getStatusCode(), ProjectTaskConstants.STATUS_COMPLETED)) { throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE); } String reason = reqVO.getReason().trim(); - int deleteCount = projectTaskMapper.deleteByIdAndStatus(taskId, fromStatus); - if (deleteCount != 1) { + String fromStatus = task.getStatusCode(); + Long parentTaskId = task.getParentTaskId(); + + // 收集自身 + 子孙任务 id,级联软删工作日志 / 协办人 / 任务 + List allTaskIds = projectTaskMapper.selectSelfAndDescendantIds(taskId); + if (allTaskIds.isEmpty()) { + // 极端兜底:CTE 未命中(理论不可能,因为 taskId 自己已经 select 出来),保持单条删 + allTaskIds = List.of(taskId); + } + taskWorklogMapper.deleteByTaskIdIn(allTaskIds); + taskAssigneeMapper.deleteByTaskIdIn(allTaskIds); + int deleteCount = projectTaskMapper.deleteByIdIn(allTaskIds); + if (deleteCount < 1) { throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED); } + + // 父任务进度回算(执行 / 项目需求 progressRate 是读时聚合,无需主动刷新) + if (parentTaskId != null) { + recalcParentProgressFrom(parentTaskId); + } + writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); } + @Override + public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) { + validateProjectExists(projectId); + ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); + ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); + // 预检也走"上级硬卡",防止"无权限的人调 precheck 试探下挂数量" + requireDeletePermissionOnTask(projectId, task, execution); + + int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId); + int worklogCount = taskWorklogMapper.countByTaskId(taskId); + + ProjectTaskDeletePrecheckRespVO respVO = new ProjectTaskDeletePrecheckRespVO(); + respVO.setChildTaskCount(childTaskCount); + respVO.setWorklogCount(worklogCount); + respVO.setHasDependentData(childTaskCount > 0 || worklogCount > 0); + return respVO; + } + + /** + * 任务删除"上级硬卡":一级任务由执行负责人 OR 项目负责人(权限码)操作; + * 子任务由父任务负责人 OR 项目负责人(权限码)操作。 + *

同时被 deleteTask 与 precheckDeleteTask 使用,保证删除路径与预检入口走同款权限通道。 + */ + private void requireDeletePermissionOnTask(Long projectId, ProjectTaskDO task, ProjectExecutionDO execution) { + Long upperOwnerId; + if (task.getParentTaskId() == null) { + upperOwnerId = execution.getOwnerId(); + } else { + ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId()); + if (parent == null) { + // 父任务被并发硬删的极端场景:当前任务已是孤儿,按"无上级负责人"处理 + // 仍要求调用方持有项目级权限码,由 checkOwnerOrProjectPermission 兜底拦截 + upperOwnerId = null; + } else { + upperOwnerId = parent.getOwnerId(); + } + } + projectObjectAuthorizationService.checkOwnerOrProjectPermission( + projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE); + } + private void validateDeleteConfirmText(String confirmText) { String normalizedConfirmText = normalizeNullableText(confirmText); if (normalizedConfirmText == null @@ -398,6 +466,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId()); respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId()); } + fillProjectRequirementInfo(List.of(respVO), execution); return respVO; } @@ -442,6 +511,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { // 执行 owner 单条查询,整页共享(URL 路径定 executionId) ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); Long executionOwnerId = execution == null ? null : execution.getOwnerId(); + fillProjectRequirementInfo(list, execution); list.forEach(vo -> { vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); List activeList = assigneeMap.getOrDefault(vo.getId(), List.of()); @@ -465,6 +535,28 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { return voPageResult; } + /** + * 把任务 RespVO 上的项目需求信息(TD-013)回填。 + *

本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage), + * 故整页 task 共享同一 execution,单次 selectById 即可拿到 ProjectRequirementDO,无 N+1。 + * 若 execution 缺失或未关联项目需求则不回填。 + */ + private void fillProjectRequirementInfo(Collection tasks, ProjectExecutionDO execution) { + if (tasks == null || tasks.isEmpty() || execution == null || execution.getProjectRequirementId() == null) { + return; + } + Long requirementId = execution.getProjectRequirementId(); + ProjectRequirementDO requirement = projectRequirementMapper.selectById(requirementId); + if (requirement == null) { + return; + } + tasks.forEach(vo -> { + vo.setProjectRequirementId(requirementId); + vo.setProjectRequirementName(requirement.getTitle()); + vo.setProjectRequirementStatusCode(requirement.getStatusCode()); + }); + } + private List buildAssigneeViews(List activeList) { if (activeList == null || activeList.isEmpty()) { return List.of(); @@ -970,6 +1062,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { valueOf(after, ProjectTaskDO::getOwnerId)); appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode), valueOf(after, ProjectTaskDO::getStatusCode)); + appendFieldChange(fieldChanges, "priority", + valueOf(before, ProjectTaskDO::getPriority), + valueOf(after, ProjectTaskDO::getPriority)); appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate), valueOf(after, ProjectTaskDO::getProgressRate)); appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectTaskDO::getPlannedStartDate), @@ -1035,6 +1130,20 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { } } + @VisibleForTesting + void validatePriority(String priority) { + try { + Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.REQ_PRIORITY, + java.util.List.of(priority.trim())).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID); + } + /** * 从给定父任务向上递归刷新进度:父进度 = AVG(直接子.progressRate),scale=2 HALF_UP。 * 终止条件:parentTaskId 为 null(已到根);或当前层进度未发生变化(截断进一步刷新)。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java index f314a46..f1e6859 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java @@ -1,5 +1,6 @@ package com.njcn.rdms.module.project.service.project.task.worklog; +import com.google.common.annotations.VisibleForTesting; import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; @@ -22,10 +23,12 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; +import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; @@ -64,6 +67,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { @Resource private AdminUserApi adminUserApi; @Resource + private DictDataApi dictDataApi; + @Resource private AttachmentFileIdResolver attachmentFileIdResolver; /** * 与 ProjectTaskService 互相依赖(ProjectTaskService 也注入本类),用 @Lazy 打破循环。 @@ -105,6 +110,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { validateDurationGranularity(reqVO.getDurationHours()); validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null); validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null); + validateDifficulty(reqVO.getDifficulty()); AttachmentValidator.validate(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments()); @@ -139,6 +145,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { // 与该用户其他工时记录不可重叠(排除自身) validateNoOverlap(taskId, worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId()); validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId()); + validateDifficulty(reqVO.getDifficulty()); AttachmentValidator.validate(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments()); @@ -148,6 +155,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { update.setEndDate(reqVO.getEndDate()); update.setDurationHours(reqVO.getDurationHours()); update.setProgressRate(reqVO.getProgressRate()); + update.setDifficulty(reqVO.getDifficulty()); update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); update.setAttachments(reqVO.getAttachments()); taskWorklogMapper.updateById(update); @@ -210,6 +218,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { worklog.setEndDate(reqVO.getEndDate()); worklog.setDurationHours(reqVO.getDurationHours()); worklog.setProgressRate(reqVO.getProgressRate()); + worklog.setDifficulty(reqVO.getDifficulty()); worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); worklog.setAttachments(reqVO.getAttachments()); worklog.setDifficulty(reqVO.getDifficulty()); @@ -395,6 +404,24 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { return nicknameMap; } + /** + * 校验完成难度为字典 {@code rdms_worklog_difficulty} 中的合法 value。 + * 走 DictDataApi 而非本地 Enum:运维加新档无需代码发版。 + */ + @VisibleForTesting + void validateDifficulty(String difficulty) { + try { + Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.WORKLOG_DIFFICULTY, + java.util.List.of(difficulty.trim())).getCheckedData(); + if (Boolean.TRUE.equals(valid)) { + return; + } + } catch (RuntimeException ex) { + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID); + } + throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID); + } + private String normalizeNullableText(String value) { if (!StringUtils.hasText(value)) { return null; diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java new file mode 100644 index 0000000..2285933 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java @@ -0,0 +1,256 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.exception.ServiceException; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; +import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +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; +import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * TD-013 引入的 {@link ProjectRequirementServiceImpl} 单测: + * 仅覆盖 validateUsableForExecution 的 4 个分支与 deleteRequirement 挂载执行的新增前置校验。 + */ +class ProjectRequirementServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private ProjectRequirementServiceImpl projectRequirementService; + @Mock + private ProjectRequirementMapper requirementMapper; + @Mock + private ProjectRequirementModuleMapper moduleMapper; + @Mock + private ProjectRequirementStatusLogMapper statusLogMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ProductRequirementMapper productRequirementMapper; + @Mock + private ProductRequirementStatusLogMapper productRequirementStatusLogMapper; + @Mock + private ObjectStatusTransitionMapper statusTransitionMapper; + @Mock + private ObjectStatusModelMapper statusModelMapper; + @Mock + private AttachmentFileIdResolver attachmentFileIdResolver; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + + @Test + void validateUsableForExecution_whenRequirementIdIsNull_shouldDoNothing() { + assertDoesNotThrow(() -> projectRequirementService.validateUsableForExecution(2001L, null)); + } + + @Test + void validateUsableForExecution_whenRequirementNotExists_shouldThrow() { + when(requirementMapper.selectById(9001L)).thenReturn(null); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectRequirementService.validateUsableForExecution(2001L, 9001L)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_EXISTS.getCode(), ex.getCode()); + } + + @Test + void validateUsableForExecution_whenRequirementNotBelongToProject_shouldThrow() { + when(requirementMapper.selectById(9001L)).thenReturn(buildRequirement(9001L, 2999L, "implementing")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectRequirementService.validateUsableForExecution(2001L, 9001L)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_BELONG_TO_PROJECT.getCode(), ex.getCode()); + } + + @Test + void validateUsableForExecution_whenRequirementTerminal_shouldThrow() { + when(requirementMapper.selectById(9001L)).thenReturn(buildRequirement(9001L, 2001L, "closed")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectRequirementService.validateUsableForExecution(2001L, 9001L)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL.getCode(), ex.getCode()); + } + + @Test + void validateUsableForExecution_whenRequirementValid_shouldDoNothing() { + when(requirementMapper.selectById(9001L)).thenReturn(buildRequirement(9001L, 2001L, "implementing")); + + assertDoesNotThrow(() -> projectRequirementService.validateUsableForExecution(2001L, 9001L)); + } + + /** + * TD-013 Q4=A:项目需求下挂着承接执行时,禁止删除;前置校验必须先于 deleteByIdAndStatus 触发。 + */ + @Test + void deleteRequirement_whenHasActiveExecutions_shouldThrow() { + Long requirementId = 9001L; + Long projectId = 2001L; + when(requirementMapper.selectById(requirementId)) + .thenReturn(buildRequirement(requirementId, projectId, "pending_confirm")); + when(requirementMapper.selectListByParentId(requirementId)).thenReturn(List.of()); + when(projectExecutionMapper.countActiveByProjectRequirementId(requirementId)).thenReturn(1L); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectRequirementService.deleteRequirement(requirementId, projectId)); + + assertEquals(ErrorCodeConstants.PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE.getCode(), ex.getCode()); + } + + private ProjectRequirementDO buildRequirement(Long id, Long projectId, String statusCode) { + ProjectRequirementDO requirement = new ProjectRequirementDO(); + requirement.setId(id); + requirement.setProjectId(projectId); + requirement.setStatusCode(statusCode); + return requirement; + } + + // ============== TD-016 进度聚合 ============== + + /** + * TD-016:叶子需求无承接执行 → 进度 0.00。 + */ + @Test + void computeRequirementProgress_whenLeafHasNoExecution_shouldBeZero() { + Long projectId = 2001L; + ProjectRequirementDO leaf = buildRequirementWithParent(9001L, projectId, "implementing", 0L); + when(requirementMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(leaf)); + when(statusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("cancelled")); + when(projectExecutionMapper.selectAvgProgressGroupByProjectRequirementIds(any(), any())) + .thenReturn(List.of()); + + Map result = projectRequirementService.computeRequirementProgressMapByProjectId(projectId); + + assertEquals(new BigDecimal("0.00"), result.get(9001L)); + } + + /** + * TD-016:叶子需求有 N 个执行,AVG(progress_rate) 即结果。 + * (SQL 层已经 GROUP BY 平均;service 只是把那个平均值放回 pool 平均,pool 大小 1 等于自身) + */ + @Test + void computeRequirementProgress_whenLeafHasExecutions_shouldUseAvg() { + Long projectId = 2001L; + ProjectRequirementDO leaf = buildRequirementWithParent(9001L, projectId, "implementing", 0L); + when(requirementMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(leaf)); + when(statusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("cancelled")); + when(projectExecutionMapper.selectAvgProgressGroupByProjectRequirementIds(any(), any())) + .thenReturn(List.of(rowOf(9001L, "0.60"))); + + Map result = projectRequirementService.computeRequirementProgressMapByProjectId(projectId); + + assertEquals(new BigDecimal("0.60"), result.get(9001L)); + } + + /** + * TD-016:父需求 = AVG(自己挂的执行的平均, 每个直接子需求进度)。 + * 例:父 9000 自己挂的执行平均 0.40;子 9001(叶子)执行平均 0.80; + * 父 = AVG(0.40, 0.80) = 0.60。子 9001 = 0.80。 + */ + @Test + void computeRequirementProgress_whenParentHasOwnExecutionsAndChildren_shouldAveragePool() { + Long projectId = 2001L; + ProjectRequirementDO parent = buildRequirementWithParent(9000L, projectId, "implementing", 0L); + ProjectRequirementDO child = buildRequirementWithParent(9001L, projectId, "implementing", 9000L); + when(requirementMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(parent, child)); + when(statusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("cancelled")); + when(projectExecutionMapper.selectAvgProgressGroupByProjectRequirementIds(any(), any())) + .thenReturn(List.of(rowOf(9000L, "0.40"), rowOf(9001L, "0.80"))); + + Map result = projectRequirementService.computeRequirementProgressMapByProjectId(projectId); + + assertEquals(new BigDecimal("0.80"), result.get(9001L)); + assertEquals(new BigDecimal("0.60"), result.get(9000L)); + } + + /** + * TD-016:父需求无自挂执行、有 2 个子需求 → 父 = AVG(子1, 子2)。 + */ + @Test + void computeRequirementProgress_whenParentHasNoOwnExecutionPureChildren_shouldAverageChildren() { + Long projectId = 2001L; + ProjectRequirementDO parent = buildRequirementWithParent(9000L, projectId, "implementing", 0L); + ProjectRequirementDO child1 = buildRequirementWithParent(9001L, projectId, "implementing", 9000L); + ProjectRequirementDO child2 = buildRequirementWithParent(9002L, projectId, "implementing", 9000L); + when(requirementMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(parent, child1, child2)); + when(statusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("cancelled")); + when(projectExecutionMapper.selectAvgProgressGroupByProjectRequirementIds(any(), any())) + .thenReturn(List.of(rowOf(9001L, "0.50"), rowOf(9002L, "1.00"))); + + Map result = projectRequirementService.computeRequirementProgressMapByProjectId(projectId); + + assertEquals(new BigDecimal("0.50"), result.get(9001L)); + assertEquals(new BigDecimal("1.00"), result.get(9002L)); + assertEquals(new BigDecimal("0.75"), result.get(9000L)); + } + + /** + * TD-016:三层结构。爷父(9000) → 父(9100) → 叶子(9101),每层都挂执行; + * 验证自下而上递归正确。 + * 叶子 9101 自挂执行 0.80 → 9101 = 0.80 + * 父 9100 自挂执行 0.40 + 子 0.80 → AVG = 0.60 + * 爷 9000 自挂执行 0.20 + 子 0.60 → AVG = 0.40 + */ + @Test + void computeRequirementProgress_multiLevel_shouldRecurseFromBottomUp() { + Long projectId = 2001L; + ProjectRequirementDO grand = buildRequirementWithParent(9000L, projectId, "implementing", 0L); + ProjectRequirementDO parent = buildRequirementWithParent(9100L, projectId, "implementing", 9000L); + ProjectRequirementDO leaf = buildRequirementWithParent(9101L, projectId, "implementing", 9100L); + when(requirementMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(grand, parent, leaf)); + when(statusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("cancelled")); + when(projectExecutionMapper.selectAvgProgressGroupByProjectRequirementIds(any(), any())) + .thenReturn(List.of(rowOf(9000L, "0.20"), rowOf(9100L, "0.40"), rowOf(9101L, "0.80"))); + + Map result = projectRequirementService.computeRequirementProgressMapByProjectId(projectId); + + assertEquals(new BigDecimal("0.80"), result.get(9101L)); + assertEquals(new BigDecimal("0.60"), result.get(9100L)); + assertEquals(new BigDecimal("0.40"), result.get(9000L)); + } + + private ProjectRequirementDO buildRequirementWithParent(Long id, Long projectId, String statusCode, Long parentId) { + ProjectRequirementDO requirement = buildRequirement(id, projectId, statusCode); + requirement.setParentId(parentId); + return requirement; + } + + private Map rowOf(Long requirementId, String avg) { + Map row = new HashMap<>(); + row.put("projectRequirementId", requirementId); + row.put("progressRate", new BigDecimal(avg)); + return row; + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java index 5cd9ee8..89bbe58 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -22,6 +22,7 @@ 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.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; @@ -30,6 +31,7 @@ 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; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; +import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; @@ -58,6 +60,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; @@ -96,9 +99,14 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { private ProjectExecutionAssigneeService projectExecutionAssigneeService; @Mock private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver; + @Mock + private ProjectRequirementService projectRequirementService; + @Mock + private ProjectRequirementMapper projectRequirementMapper; /** * 默认让 VisibilityScopeResolver 放行(seesAll=true),既有测试无需关心 scope。 + * 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true,既有测试不因 priority 校验失败。 * 真正需要测试 scope 行为的用例可在方法内显式覆盖。 */ @BeforeEach @@ -107,6 +115,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all()); lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any())) .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all()); + lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) + .thenReturn(success(true)); } @Test @@ -154,8 +164,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class)); } + /** + * TD-013:传入非空 projectRequirementId 时,必须调用 ProjectRequirementService.validateUsableForExecution; + * 校验抛错时执行模块应原样透传错误码(不存在 / 不属本项目 / 终态三类内部分支由项目需求模块自测)。 + */ @Test - void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() { + void createExecution_whenRequirementValidationFails_shouldPropagateError() { Long projectId = 2001L; ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); reqVO.setExecutionName("后端接口联调"); @@ -165,11 +179,54 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) .thenReturn(createProjectStatus("pending", true)); + doThrow(new ServiceException(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL)) + .when(projectRequirementService).validateUsableForExecution(projectId, 9001L); ServiceException ex = assertThrows(ServiceException.class, () -> projectExecutionService.createExecution(projectId, reqVO)); - assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY.getCode(), ex.getCode()); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL.getCode(), ex.getCode()); + verify(projectRequirementService).validateUsableForExecution(projectId, 9001L); + } + + /** + * TD-013:传入合法 projectRequirementId 且校验通过时,落库时该字段应原样写入,不再被清空。 + */ + @Test + void createExecution_whenRequirementIdValid_shouldInsertWithRequirementId() { + Long projectId = 2001L; + ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setProjectRequirementId(9001L); + reqVO.setAssigneeUserIds(List.of(3002L)); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution")) + .thenReturn(createExecutionStatus("pending", true)); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L)) + .thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L))); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); + when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature"))) + .thenReturn(success(true)); + when(projectExecutionMapper.insert(any(ProjectExecutionDO.class))).thenAnswer(invocation -> { + ProjectExecutionDO execution = invocation.getArgument(0); + execution.setId(5001L); + return 1; + }); + + Long executionId = projectExecutionService.createExecution(projectId, reqVO); + + assertNotNull(executionId); + ArgumentCaptor executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class); + verify(projectExecutionMapper).insert(executionCaptor.capture()); + assertEquals(9001L, executionCaptor.getValue().getProjectRequirementId()); + verify(projectRequirementService).validateUsableForExecution(projectId, 9001L); } @Test @@ -203,6 +260,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionType("feature"); reqVO.setOwnerId(3001L); + reqVO.setPriority("1"); reqVO.setAssigneeUserIds(List.of()); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); @@ -229,6 +287,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { reqVO.setId(executionId); reqVO.setExecutionName("接口联调-修订"); reqVO.setExecutionType("feature"); + reqVO.setPriority("1"); reqVO.setProjectRequirementId(null); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); @@ -292,6 +351,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start")) .thenReturn(createTransition("start", "active", false)); when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(1); + // maybeFillActualDates:fromStatus="pending" 的 initialFlag=true → 触发 actualStartDate 填充并调 updateById + ObjectStatusModelDO pendingInitialStatus = createExecutionStatus("pending", true); + pendingInitialStatus.setInitialFlag(Boolean.TRUE); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(pendingInitialStatus); projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO); @@ -312,6 +376,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() { Long projectId = 2001L; Long executionId = 5001L; + // ownerId=3001L;cancel 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); reqVO.setActionCode("cancel"); @@ -322,10 +387,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel")) .thenReturn(createTransition("cancel", "cancelled", true)); - ServiceException ex = assertThrows(ServiceException.class, - () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); - - assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); + try (MockedStatic mockedStatic = mockLoginUser(3001L)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); + } } @Test @@ -352,6 +418,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { void changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow() { Long projectId = 2001L; Long executionId = 5001L; + // ownerId=3001L;complete 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); execution.setStatusCode("active"); ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); @@ -362,10 +429,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete")) .thenReturn(null); - ServiceException ex = assertThrows(ServiceException.class, - () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); - - assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); + try (MockedStatic mockedStatic = mockLoginUser(3001L)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); + } } @Test @@ -594,6 +662,148 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { assertEquals(0L, result.getTotal()); } + // -------------------- priority 单测 -------------------- + + /** + * 传 null priority → validatePriority(null) 内部 NPE 被 catch → 抛 PROJECT_EXECUTION_PRIORITY_INVALID。 + * 注:测试架构直接调 service,不经过 Bean Validation,@NotBlank 不触发。 + */ + @Test + void createExecution_whenPriorityNull_shouldThrowPriorityInvalid() { + Long projectId = 2001L; + ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); + reqVO.setExecutionName("接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setAssigneeUserIds(List.of(3002L)); + reqVO.setPriority(null); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) + .thenReturn(success(true)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.createExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID.getCode(), ex.getCode()); + } + + /** + * 传非字典值 "99" → dictDataApi 对 REQ_PRIORITY 抛 RuntimeException → 抛 PROJECT_EXECUTION_PRIORITY_INVALID。 + */ + @Test + void createExecution_whenPriorityInvalid_shouldThrowPriorityInvalid() { + Long projectId = 2001L; + ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); + reqVO.setExecutionName("接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setAssigneeUserIds(List.of(3002L)); + reqVO.setPriority("99"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); + // EXECUTION_TYPE 校验通过 + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) + .thenReturn(success(true)); + // REQ_PRIORITY 校验抛异常 + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) + .thenThrow(new RuntimeException("非法字典值")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> projectExecutionService.createExecution(projectId, reqVO)); + + assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID.getCode(), ex.getCode()); + } + + /** + * 传合法值 "0" → 落库,DO.priority == "0"。 + */ + @Test + void createExecution_whenPriorityValid_shouldPersistPriority() { + Long projectId = 2001L; + ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); + reqVO.setExecutionName("后端接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setOwnerId(3001L); + reqVO.setAssigneeUserIds(List.of(3002L)); + reqVO.setPriority("0"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution")) + .thenReturn(createExecutionStatus("pending", true)); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L)) + .thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L))); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null); + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) + .thenReturn(success(true)); + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) + .thenReturn(success(true)); + when(projectExecutionMapper.insert(any(ProjectExecutionDO.class))).thenAnswer(invocation -> { + ProjectExecutionDO execution = invocation.getArgument(0); + execution.setId(5001L); + return 1; + }); + + projectExecutionService.createExecution(projectId, reqVO); + + ArgumentCaptor executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class); + verify(projectExecutionMapper).insert(executionCaptor.capture()); + assertEquals("0", executionCaptor.getValue().getPriority()); + } + + /** + * updateExecution 时 priority 从 "1" 改为 "0" → bizAuditLog 写入的 fieldChanges JSON 含 "priority" 键。 + */ + @Test + void updateExecution_whenPriorityChanged_shouldRecordPriorityFieldChange() { + Long projectId = 2001L; + Long executionId = 5001L; + ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); + execution.setPriority("1"); + ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO(); + reqVO.setId(executionId); + reqVO.setExecutionName("接口联调"); + reqVO.setExecutionType("feature"); + reqVO.setProjectRequirementId(null); + reqVO.setPriority("0"); + + when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) + .thenReturn(createProjectStatus("pending", true)); + when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) + .thenReturn(createExecutionStatus("pending", true)); + when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); + when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null); + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L)) + .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L))); + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any())) + .thenReturn(success(true)); + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) + .thenReturn(success(true)); + + projectExecutionService.updateExecution(projectId, reqVO); + + ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class); + verify(bizAuditLogMapper).insert(auditCaptor.capture()); + assertNotNull(auditCaptor.getValue().getFieldChanges()); + // fieldChanges JSON 必须包含 priority 字段变更 + assertEquals(true, auditCaptor.getValue().getFieldChanges().contains("priority")); + } + private ProjectDO createEditableProject(Long projectId) { ProjectDO project = new ProjectDO(); project.setId(projectId); @@ -664,4 +874,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { user.setNickname(nickname); return user; } + + private MockedStatic mockLoginUser(Long loginUserId) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + return mockedStatic; + } } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplValidateDifficultyTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplValidateDifficultyTest.java new file mode 100644 index 0000000..a31a6f8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImplValidateDifficultyTest.java @@ -0,0 +1,61 @@ +package com.njcn.rdms.module.project.service.project.task.worklog; + +import com.njcn.rdms.framework.common.exception.ServiceException; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; +import com.njcn.rdms.module.system.api.dict.DictDataApi; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * 仅测 {@link TaskWorklogServiceImpl#validateDifficulty(String)} 的三个分支。 + * createWorklog / updateWorklog 端到端单测留待后续单测建设独立立项。 + */ +class TaskWorklogServiceImplValidateDifficultyTest extends BaseMockitoUnitTest { + + @InjectMocks + private TaskWorklogServiceImpl taskWorklogService; + + @Mock + private DictDataApi dictDataApi; + + @Test + void validateDifficulty_whenDictReturnsTrue_shouldNotThrow() { + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.WORKLOG_DIFFICULTY), any())) + .thenReturn(success(true)); + + assertDoesNotThrow(() -> taskWorklogService.validateDifficulty("2")); + } + + @Test + void validateDifficulty_whenDictReturnsFalse_shouldThrowInvalid() { + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.WORKLOG_DIFFICULTY), any())) + .thenReturn(success(false)); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskWorklogService.validateDifficulty("99")); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID.getCode(), ex.getCode()); + } + + @Test + void validateDifficulty_whenDictThrowsRuntimeException_shouldThrowInvalid() { + when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.WORKLOG_DIFFICULTY), any())) + .thenThrow(new RuntimeException("非法字典值")); + + ServiceException ex = assertThrows(ServiceException.class, + () -> taskWorklogService.validateDifficulty("99")); + + assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID.getCode(), ex.getCode()); + } +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java index fdb7d7d..eba8c1d 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java @@ -103,9 +103,9 @@ public class DictDataController { } @GetMapping("/page") - @Operation(summary = "获得字典类型的分页") + @Operation(summary = "获得字典数据的分页") @PreAuthorize("@ss.hasPermission('system:dict:query')") - public CommonResult> getDictTypePage(@Valid DictDataPageReqVO pageReqVO) { + public CommonResult> getDictDataPage(@Valid DictDataPageReqVO pageReqVO) { PageResult pageResult = dictDataService.getDictDataPage(pageReqVO); return success(BeanUtils.toBean(pageResult, DictDataRespVO.class)); } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataPageReqVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataPageReqVO.java index 3cf5efb..90fe843 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataPageReqVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataPageReqVO.java @@ -4,11 +4,12 @@ import com.njcn.rdms.framework.common.enums.CommonStatusEnum; import com.njcn.rdms.framework.common.pojo.PageParam; import com.njcn.rdms.framework.common.validation.InEnum; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data; import lombok.EqualsAndHashCode; -@Schema(description = "管理后台 - 字典类型分页列表 Request VO") +@Schema(description = "管理后台 - 字典数据分页列表 Request VO") @Data @EqualsAndHashCode(callSuper = true) public class DictDataPageReqVO extends PageParam { @@ -17,8 +18,9 @@ public class DictDataPageReqVO extends PageParam { @Size(max = 100, message = "字典标签长度不能超过100个字符") private String label; - @Schema(description = "字典类型,模糊匹配", example = "sys_common_sex") - @Size(max = 100, message = "字典类型类型长度不能超过100个字符") + @Schema(description = "字典类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "sys_common_sex") + @NotBlank(message = "字典类型不能为空") + @Size(max = 100, message = "字典类型长度不能超过100个字符") private String dictType; @Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1") diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java index 043f37b..913f2e8 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java @@ -28,4 +28,7 @@ public class DictDataSimpleRespVO { @Schema(description = "css 样式", example = "btn-visible") private String cssClass; + @Schema(description = "备注", example = "仅内部使用") + private String remark; + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapper.java index c51c630..ca5ca10 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapper.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapper.java @@ -8,7 +8,6 @@ import com.njcn.rdms.module.system.dal.dataobject.dict.DictDataDO; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.apache.ibatis.annotations.Mapper; -import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -37,7 +36,8 @@ public interface DictDataMapper extends BaseMapperX { .likeIfPresent(DictDataDO::getLabel, reqVO.getLabel()) .eqIfPresent(DictDataDO::getDictType, reqVO.getDictType()) .eqIfPresent(DictDataDO::getStatus, reqVO.getStatus()) - .orderByDesc(Arrays.asList(DictDataDO::getDictType, DictDataDO::getSort))); + .orderByAsc(DictDataDO::getSort) + .orderByAsc(DictDataDO::getId)); } default List selectListByStatusAndDictType(Integer status, String dictType) { diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapperTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapperTest.java new file mode 100644 index 0000000..767e044 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/dal/mysql/dict/DictDataMapperTest.java @@ -0,0 +1,61 @@ +package com.njcn.rdms.module.system.dal.mysql.dict; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.system.controller.admin.dict.vo.data.DictDataPageReqVO; +import com.njcn.rdms.module.system.dal.dataobject.dict.DictDataDO; +import jakarta.validation.constraints.NotBlank; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.lang.reflect.Field; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class DictDataMapperTest { + + @BeforeAll + static void initMyBatisPlusTableInfo() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), DictDataDO.class); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void selectPage_shouldOrderBySortAscForSingleDictType() { + DictDataMapper mapper = mock(DictDataMapper.class, invocation -> invocation.callRealMethod()); + DictDataPageReqVO reqVO = new DictDataPageReqVO(); + reqVO.setDictType("sys_common_status"); + doReturn(PageResult.empty()).when(mapper).selectPage(eq(reqVO), any(Wrapper.class)); + + mapper.selectPage(reqVO); + + ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(Wrapper.class); + verify(mapper).selectPage(eq(reqVO), wrapperCaptor.capture()); + String sqlSegment = wrapperCaptor.getValue().getSqlSegment().toLowerCase(Locale.ROOT); + assertTrue(sqlSegment.contains("dict_type")); + assertTrue(sqlSegment.contains("order by sort asc,id asc")); + assertFalse(sqlSegment.contains("dict_type desc")); + assertFalse(sqlSegment.contains("sort desc")); + } + + @Test + void pageReqVO_shouldRequireDictType() throws NoSuchFieldException { + Field dictTypeField = DictDataPageReqVO.class.getDeclaredField("dictType"); + + NotBlank notBlank = dictTypeField.getAnnotation(NotBlank.class); + + assertNotNull(notBlank); + assertEquals("字典类型不能为空", notBlank.message()); + } + +}