fix(project): 修复项目执行管理中的多项问题并优化字典数据功能

- 修复字典数据分页接口命名错误,从 getDictTypePage 改为 getDictDataPage
- 修复字典数据查询排序逻辑,从 orderByDesc 改为 orderByAsc 并增加 id 排序
- 更新字典数据分页请求参数验证,将 dictType 设为必填项并添加非空验证
- 在字典数据简单响应对象中添加备注字段
- 修复项目执行删除权限验证,允许非初始态执行删除但阻止已完成执行删除
- 添加项目执行和任务优先级验证错误码常量
- 优化项目执行删除逻辑,支持级联软删相关任务、工作日志和协办数据
- 添加项目需求关联验证,防止无效需求关联到执行
- 修复执行协办数据批量删除方法的参数验证逻辑
- 添加工时完成难度验证错误码,完善项目需求删除前检查机制
- 更新 CLAUDE.md 文档,补充种子 SQL 编写规范和雪花 ID 处理说明
This commit is contained in:
2026-05-21 21:17:54 +08:00
parent 19637d74a4
commit 1bee5eb05b
45 changed files with 1439 additions and 76 deletions

View File

@@ -73,7 +73,39 @@
"Bash(mv \"项目/rdms_biz_audit_log.sql\" sql/)", "Bash(mv \"项目/rdms_biz_audit_log.sql\" sql/)",
"Bash(mv \"项目/sql/\"*.sql sql/)", "Bash(mv \"项目/sql/\"*.sql sql/)",
"Bash(mv \"项目/项目管理待确认项清单_V1.0.md\" domains/project/)", "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)"
] ]
} }
} }

View File

@@ -106,6 +106,16 @@
- SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。 - 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不要用"改成英文"规避乱码。 - 关键字段/分支/约束/非直观实现补**简洁中文**注释;中文写入必须 UTF-8不要用"改成英文"规避乱码。

View File

@@ -123,6 +123,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人"); 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_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行协办人已失效"); 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_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行"); ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行");
ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接"); 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_ASSIGNEE_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行协办人");
ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行【{}】动作"); 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_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_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_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 ========== // ========== 任务管理 1-008-004-000 ==========
ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(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_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_COMPLETE_CHILDREN_REQUIRED = new ErrorCode(1_008_004_010, "父任务完成前,子任务必须全部完成或取消");
ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作"); 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_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_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_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零");
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录"); 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_RANGE_INVALID = new ErrorCode(1_008_006_007, "段起始日期不能晚于段结束日期");
ErrorCode PROJECT_TASK_WORKLOG_DATE_OVERLAP = new ErrorCode(1_008_006_008, "日期范围与该任务下您已有的工时记录重叠"); 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_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 ========== // ========== 任务 / 工时附件 1_008_007_xxx ==========
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个"); 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_CHILDREN = new ErrorCode(1_008_007_015, "存在子模块,请先删除子模块");
ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_007_016, "模块下存在项目需求,请先删除需求"); 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_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"); 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 ========== // ========== 个人事项 1_008_008_xxx ==========

View File

@@ -15,4 +15,14 @@ public interface ProjectDictTypeConstants {
*/ */
String EXECUTION_TYPE = "rdms_project_execution_type"; String EXECUTION_TYPE = "rdms_project_execution_type";
/**
* 优先级(任务 / 执行 共用P0=最高 ~ P3=最低)。
*/
String REQ_PRIORITY = "rdms_req_priority";
/**
* 工时完成难度。
*/
String WORKLOG_DIFFICULTY = "rdms_worklog_difficulty";
} }

View File

@@ -51,6 +51,12 @@ public final class ProjectExecutionConstants {
*/ */
public static final String PERMISSION_DELETE = "project:execution:delete"; 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" 与中文 "删除",前端可纯中文文案。 * 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
* 校验时精确匹配trim 后比对)。 * 校验时精确匹配trim 后比对)。

View File

@@ -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.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.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.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.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.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
@@ -92,8 +93,16 @@ public class ProjectExecutionController {
return success(true); return success(true);
} }
@GetMapping("/{executionId}/delete-precheck")
@Operation(summary = "执行删除预检:返回下挂任务数 + 是否需要重型确认")
public CommonResult<ProjectExecutionDeletePrecheckRespVO> precheckDeleteExecution(
@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId) {
return success(projectExecutionService.precheckDeleteExecution(projectId, executionId));
}
@DeleteMapping("/{executionId}") @DeleteMapping("/{executionId}")
@Operation(summary = "删除执行(仅初始态可删,三重确认)") @Operation(summary = "删除执行(已完成态禁止,三重确认)")
public CommonResult<Boolean> deleteExecution(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> deleteExecution(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@Valid @RequestBody ProjectExecutionDeleteReqVO reqVO) { @Valid @RequestBody ProjectExecutionDeleteReqVO reqVO) {

View File

@@ -29,11 +29,17 @@ public class ProjectExecutionCreateReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符") @Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType; 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") @Schema(description = "执行负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001")
@NotNull(message = "执行负责人不能为空") @NotNull(message = "执行负责人不能为空")
private Long ownerId; private Long ownerId;
@Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001") @Schema(description = "关联项目需求编号,可选;不传或传 null 表示无主执行。传入值必须属于本项目且需求非终态", example = "9001")
private Long projectRequirementId; private Long projectRequirementId;
@Schema(description = "计划开始日期") @Schema(description = "计划开始日期")

View File

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

View File

@@ -30,6 +30,10 @@ public class ProjectExecutionPageReqVO extends PageParam {
@Size(max = 32, message = "执行状态编码长度不能超过32个字符") @Size(max = 32, message = "执行状态编码长度不能超过32个字符")
private String statusCode; 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]") @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime; private LocalDateTime[] updateTime;

View File

@@ -18,6 +18,10 @@ public class ProjectExecutionRespVO {
private Long projectId; private Long projectId;
@Schema(description = "关联项目需求编号") @Schema(description = "关联项目需求编号")
private Long projectRequirementId; 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 = "后端接口联调") @Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
private String executionName; private String executionName;
@Schema(description = "执行类型", example = "feature") @Schema(description = "执行类型", example = "feature")
@@ -30,6 +34,10 @@ public class ProjectExecutionRespVO {
private String statusCode; private String statusCode;
@Schema(description = "执行状态名称", example = "待开始") @Schema(description = "执行状态名称", example = "待开始")
private String statusName; private String statusName;
@Schema(description = "优先级编码(字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)", example = "0")
private String priority;
@Schema(description = "优先级名称(字典 label后端不填、前端按字典 cache 自译)", example = "P0")
private String priorityName;
@Schema(description = "是否终态", example = "false") @Schema(description = "是否终态", example = "false")
private Boolean terminal; private Boolean terminal;
@Schema(description = "当前状态是否允许编辑", example = "true") @Schema(description = "当前状态是否允许编辑", example = "true")

View File

@@ -30,7 +30,13 @@ public class ProjectExecutionUpdateReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符") @Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType; 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; private Long projectRequirementId;
@Schema(description = "计划开始日期") @Schema(description = "计划开始日期")

View File

@@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO; 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.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
@@ -94,8 +95,17 @@ public class ProjectTaskController {
return success(true); return success(true);
} }
@GetMapping("/{taskId}/delete-precheck")
@Operation(summary = "任务删除预检:返回下挂子任务数 + 工作日志数 + 是否需要重型确认")
public CommonResult<ProjectTaskDeletePrecheckRespVO> precheckDeleteTask(
@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId) {
return success(projectTaskService.precheckDeleteTask(projectId, executionId, taskId));
}
@DeleteMapping("/{taskId}") @DeleteMapping("/{taskId}")
@Operation(summary = "删除任务(仅初始态可删,三重确认 + 执行 owner 硬卡)") @Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡)")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import com.njcn.rdms.framework.common.pojo.PageParam; import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
@@ -25,6 +26,10 @@ public class ProjectTaskBoardPageReqVO extends PageParam {
example = "[\"pending\",\"active\"]") example = "[\"pending\",\"active\"]")
private String[] statusCode; private String[] statusCode;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) ~ 3=P3(最低)", example = "0")
@Size(max = 8)
private String priority;
@Schema(description = "关键词,匹配任务标题", example = "联调") @Schema(description = "关键词,匹配任务标题", example = "联调")
private String keyword; private String keyword;

View File

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

View File

@@ -29,6 +29,10 @@ public class ProjectTaskPageReqVO extends PageParam {
@Size(max = 32, message = "任务状态编码长度不能超过32个字符") @Size(max = 32, message = "任务状态编码长度不能超过32个字符")
private String statusCode; 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]") @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime; private LocalDateTime[] updateTime;

View File

@@ -21,6 +21,12 @@ public class ProjectTaskRespVO {
private Long projectId; private Long projectId;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId; 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 = "父任务编号") @Schema(description = "父任务编号")
private Long parentTaskId; private Long parentTaskId;
@Schema(description = "父任务负责人用户编号;一级任务为 null子任务用于前端判断" @Schema(description = "父任务负责人用户编号;一级任务为 null子任务用于前端判断"
@@ -41,6 +47,10 @@ public class ProjectTaskRespVO {
private String statusCode; private String statusCode;
@Schema(description = "任务状态名称", example = "待开始") @Schema(description = "任务状态名称", example = "待开始")
private String statusName; private String statusName;
@Schema(description = "优先级编码(字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)", example = "0")
private String priority;
@Schema(description = "优先级名称(字典 label后端不填、前端按字典 cache 自译)", example = "P0")
private String priorityName;
@Schema(description = "是否终态", example = "false") @Schema(description = "是否终态", example = "false")
private Boolean terminal; private Boolean terminal;
@Schema(description = "当前状态是否允许编辑", example = "true") @Schema(description = "当前状态是否允许编辑", example = "true")

View File

@@ -44,6 +44,12 @@ public class ProjectTaskSaveReqVO {
@Size(max = 200000, message = "任务说明长度不能超过200000个字符") @Size(max = 200000, message = "任务说明长度不能超过200000个字符")
private String taskDesc; 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 = "初始协办人用户编号列表;仅在创建任务时生效,编辑任务时静默忽略。" @Schema(description = "初始协办人用户编号列表;仅在创建任务时生效,编辑任务时静默忽略。"
+ "协办人通过独立接口管理,详见 /tasks/{id}/assignees") + "协办人通过独立接口管理,详见 /tasks/{id}/assignees")
private List<Long> assigneeUserIds; private List<Long> assigneeUserIds;

View File

@@ -21,4 +21,8 @@ public class TaskWorklogPageReqVO extends PageParam {
@Schema(description = "查询区间截止日期按段相交过滤record.startDate <= endDate") @Schema(description = "查询区间截止日期按段相交过滤record.startDate <= endDate")
private LocalDate endDate; private LocalDate endDate;
@Schema(description = "完成难度字典 rdms_worklog_difficulty 值;不传表示全部",
example = "3")
private String difficulty;
} }

View File

@@ -37,6 +37,12 @@ public class TaskWorklogRespVO {
@Schema(description = "本次填报进度0~100", example = "60.00") @Schema(description = "本次填报进度0~100", example = "60.00")
private BigDecimal progressRate; private BigDecimal progressRate;
@Schema(description = "完成难度编码(字典 rdms_worklog_difficulty", example = "2")
private String difficulty;
@Schema(description = "完成难度名称(字典 label后端不填、前端按字典 cache 自译)", example = "一般")
private String difficultyName;
@Schema(description = "工作内容描述") @Schema(description = "工作内容描述")
private String workContent; private String workContent;

View File

@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@@ -46,6 +47,12 @@ public class TaskWorklogSaveReqVO {
@DecimalMax(value = "100.00", message = "本次填报进度不能大于 100") @DecimalMax(value = "100.00", message = "本次填报进度不能大于 100")
private BigDecimal progressRate; 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 = "完成接口联调与冒烟测试") @Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试")
@Size(max = 2000, message = "工作内容长度不能超过 2000 个字符") @Size(max = 2000, message = "工作内容长度不能超过 2000 个字符")
private String workContent; private String workContent;
@@ -53,7 +60,4 @@ public class TaskWorklogSaveReqVO {
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)") @Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)")
@Valid @Valid
private List<AttachmentItem> attachments; private List<AttachmentItem> attachments;
@Schema(description = "任务难度", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private String difficulty;
} }

View File

@@ -4,6 +4,7 @@ import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -96,4 +97,11 @@ public class ProjectRequirementRespVO {
@Schema(description = "是否为终态", example = "false") @Schema(description = "是否为终态", example = "false")
private Boolean terminal; 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;
} }

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.dal.dataobject.project.execution; 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.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
@@ -27,8 +29,9 @@ public class ProjectExecutionDO extends BaseDO {
*/ */
private Long projectId; private Long projectId;
/** /**
* 关联项目需求编号,第一阶段仅保留字段 * 关联项目需求编号。PUT 全字段语义null=解绑(参见 CLAUDE.md 接口语义章节)。
*/ */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long projectRequirementId; private Long projectRequirementId;
/** /**
* 执行名称 * 执行名称
@@ -46,6 +49,10 @@ public class ProjectExecutionDO extends BaseDO {
* 执行状态编码 * 执行状态编码
*/ */
private String statusCode; private String statusCode;
/**
* 优先级字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)
*/
private String priority;
/** /**
* 计划开始日期 * 计划开始日期
*/ */

View File

@@ -54,6 +54,10 @@ public class ProjectTaskDO extends BaseDO {
* 任务状态编码 * 任务状态编码
*/ */
private String statusCode; private String statusCode;
/**
* 优先级字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)
*/
private String priority;
/** /**
* 任务进度 * 任务进度
*/ */

View File

@@ -58,6 +58,11 @@ public class TaskWorklogDO extends BaseDO {
* </ul> * </ul>
*/ */
private BigDecimal progressRate; private BigDecimal progressRate;
/**
* 完成难度字典 {@code rdms_worklog_difficulty}
* "1" 简单 / "2" 一般 / "3" 困难 / "4" 超难,数值大=越难。必填DDL 默认 "2"。
*/
private String difficulty;
/** /**
* 工作内容描述。允许在 update 时传 null 清空updateStrategy=ALWAYS 跳过全局 NOT_NULL 策略, * 工作内容描述。允许在 update 时传 null 清空updateStrategy=ALWAYS 跳过全局 NOT_NULL 策略,
* 始终参与 SQL 拼接,包括 null。调用方约定update 必须全字段回传,不能用 null 表示"未改动"。 * 始终参与 SQL 拼接,包括 null。调用方约定update 必须全字段回传,不能用 null 表示"未改动"。
@@ -71,5 +76,4 @@ public class TaskWorklogDO extends BaseDO {
@TableField(typeHandler = JacksonTypeHandler.class) @TableField(typeHandler = JacksonTypeHandler.class)
private List<AttachmentItem> attachments; private List<AttachmentItem> attachments;
private String difficulty;
} }

View File

@@ -67,4 +67,15 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId, List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId); @Param("userId") Long userId);
/**
* 按 execution_id 批量软删执行协办(含已 removed 的历史段)。
*/
default int deleteByExecutionId(Long executionId) {
if (executionId == null) {
return 0;
}
return delete(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getExecutionId, executionId));
}
} }

View File

@@ -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.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
@Mapper @Mapper
public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO> { public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO> {
@@ -35,14 +39,16 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
if (!scope.seesAll() && scope.executionIds().isEmpty()) { if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return PageResult.empty(); return PageResult.empty();
} }
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>() LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<>();
.eq(ProjectExecutionDO::getProjectId, projectId) queryWrapper.eq(ProjectExecutionDO::getProjectId, projectId);
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()) queryWrapper.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType());
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId()) queryWrapper.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId());
.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode()) queryWrapper.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode());
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()) queryWrapper.eqIfPresent(ProjectExecutionDO::getPriority, reqVO.getPriority());
.orderByDesc(BaseDO::getCreateTime) queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
.orderByDesc(ProjectExecutionDO::getId); queryWrapper.orderByAsc(ProjectExecutionDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword())); queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
} }
@@ -106,6 +112,40 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
return Math.toIntExact(selectCount(queryWrapper)); return Math.toIntExact(selectCount(queryWrapper));
} }
/**
* TD-016按 projectRequirementIds 批量聚合承接执行的平均进度,避免列表 N+1。
* <p>口径:仅统计 deleted = 0 + status_code NOT IN(excludedStatusCodes) 的执行;
* excludedStatusCodes 由 rdms_object_status_model.progress_excluded_flag=1 动态读取,不在代码硬编码。
* 返回行格式:{ projectRequirementId, progressRate }(仅出现的 requirementId 才有行,无承接执行的不返回)。
*/
@Select("""
<script>
SELECT project_requirement_id AS projectRequirementId,
AVG(COALESCE(progress_rate, 0)) AS progressRate
FROM rdms_project_execution
WHERE deleted = b'0'
AND project_requirement_id IN
<foreach collection="projectRequirementIds" item="id" open="(" separator="," close=")">#{id}</foreach>
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
GROUP BY project_requirement_id
</script>
""")
List<Map<String, Object>> selectAvgProgressGroupByProjectRequirementIds(
@Param("projectRequirementIds") Collection<Long> projectRequirementIds,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 统计指定项目需求下的有效(未软删)承接执行数。
* 用于项目需求删除前的"先解绑/转移承接执行"前置校验。
*/
default long countActiveByProjectRequirementId(Long projectRequirementId) {
return selectCount(new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectRequirementId, projectRequirementId));
}
default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) {
ProjectExecutionDO update = new ProjectExecutionDO(); ProjectExecutionDO update = new ProjectExecutionDO();
update.setStatusCode(toStatus); update.setStatusCode(toStatus);

View File

@@ -43,8 +43,10 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId()); queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId());
queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId()); queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode()); queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectTaskDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId); queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByAsc(ProjectTaskDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime); queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId); queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
@@ -319,4 +321,62 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
return Math.toIntExact(selectCount(queryWrapper)); return Math.toIntExact(selectCount(queryWrapper));
} }
/**
* 收集执行下的所有任务 id含子孙——子孙必然同 execution_id所以一把抓即可
* 用于"删除执行"时的级联软删。
*/
default List<Long> selectIdsByExecutionId(Long executionId) {
if (executionId == null) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
.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<Long> selectSelfAndDescendantIds(@Param("taskId") Long taskId);
/**
* 按 id 集合批量软删任务。@TableLogic 自动落 deleted=1。
*/
default int deleteByIdIn(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
return delete(new LambdaQueryWrapperX<ProjectTaskDO>()
.in(ProjectTaskDO::getId, ids));
}
/**
* 执行下任务计数(用于删除预检)。
*/
default int countByExecutionId(Long executionId) {
if (executionId == null) {
return 0;
}
return Math.toIntExact(selectCount(new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getExecutionId, executionId)));
}
} }

View File

@@ -94,4 +94,16 @@ public interface TaskAssigneeMapper extends BaseMapperX<TaskAssigneeDO> {
.eq(TaskAssigneeDO::getTaskId, taskId)); .eq(TaskAssigneeDO::getTaskId, taskId));
} }
/**
* 按 task_id 集合批量软删任务协办(含已 removed 的历史段,统一标 deleted=1
* 协办人不参与"是否有下挂数据"判定,但级联删任务时仍要一并清理。
*/
default int deleteByTaskIdIn(Collection<Long> taskIds) {
if (taskIds == null || taskIds.isEmpty()) {
return 0;
}
return delete(new LambdaQueryWrapperX<TaskAssigneeDO>()
.in(TaskAssigneeDO::getTaskId, taskIds));
}
} }

View File

@@ -35,7 +35,8 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
default PageResult<TaskWorklogDO> selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) { default PageResult<TaskWorklogDO> selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) {
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>() LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId) .eq(TaskWorklogDO::getTaskId, taskId)
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId()); .eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId())
.eqIfPresent(TaskWorklogDO::getDifficulty, reqVO.getDifficulty());
if (reqVO.getEndDate() != null) { if (reqVO.getEndDate() != null) {
queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate()); queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate());
} }
@@ -165,4 +166,26 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
return selectCount(queryWrapper) > 0; return selectCount(queryWrapper) > 0;
} }
/**
* 按 task_id 集合批量软删工作日志。用于级联删除任务时一并清理。
*/
default int deleteByTaskIdIn(Collection<Long> taskIds) {
if (taskIds == null || taskIds.isEmpty()) {
return 0;
}
return delete(new LambdaQueryWrapperX<TaskWorklogDO>()
.in(TaskWorklogDO::getTaskId, taskIds));
}
/**
* 单任务工作日志条数(用于删除预检)。
*/
default int countByTaskId(Long taskId) {
if (taskId == null) {
return 0;
}
return Math.toIntExact(selectCount(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)));
}
} }

View File

@@ -113,4 +113,12 @@ public interface ProjectRequirementService {
*/ */
List<ProjectRequirementStatusDictRespVO> getRequirementTerminalStatusDict(); List<ProjectRequirementStatusDictRespVO> getRequirementTerminalStatusDict();
/**
* 校验项目需求可被执行关联。
* <p>用于执行模块在 create / update 时确认传入的 projectRequirementId 合法。
* 校验:存在且未软删 + 属于本项目 + 非终态。任一不满足抛 ServiceException。
* 传入 requirementId 为 null 时直接放行(执行侧已确定 projectRequirementId 可选)。
*/
void validateUsableForExecution(Long projectId, Long projectRequirementId);
} }

View File

@@ -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.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; 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.ProjectRequirementAllowedTransitionBatchRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO;
@@ -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.ProjectRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; 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.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.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; 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.ErrorCodeConstants;
@@ -46,6 +48,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -120,6 +124,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
private ObjectStatusModelMapper statusModelMapper; private ObjectStatusModelMapper statusModelMapper;
@Resource @Resource
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -199,7 +205,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
public ProjectRequirementRespVO getRequirement(Long id, Long projectId) { public ProjectRequirementRespVO getRequirement(Long id, Long projectId) {
ProjectRequirementDO requirement = validateRequirementExists(id); ProjectRequirementDO requirement = validateRequirementExists(id);
validateRequirementBelongsToProject(requirement, projectId); validateRequirementBelongsToProject(requirement, projectId);
return buildRequirementRespVO(requirement); ProjectRequirementRespVO respVO = buildRequirementRespVO(requirement);
fillRequirementProgress(projectId, List.of(respVO));
return respVO;
} }
@Override @Override
@@ -220,6 +228,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
List<ProjectRequirementRespVO> list = pageResult.getList().stream() List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap)) .map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList()); .collect(Collectors.toList());
fillRequirementProgress(pageReqVO.getProjectId(), list);
return new PageResult<>(list, pageResult.getTotal()); return new PageResult<>(list, pageResult.getTotal());
} }
@@ -274,6 +283,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
List<ProjectRequirementRespVO> list = pagedRootRequirements.stream() List<ProjectRequirementRespVO> list = pagedRootRequirements.stream()
.map(requirement -> buildRequirementRespVOWithPathChildren(requirement, pathNodeIds, childrenMap, statusModelMap)) .map(requirement -> buildRequirementRespVOWithPathChildren(requirement, pathNodeIds, childrenMap, statusModelMap))
.collect(Collectors.toList()); .collect(Collectors.toList());
fillRequirementProgress(projectId, list);
return new PageResult<>(list, (long) total); return new PageResult<>(list, (long) total);
} }
@@ -336,6 +346,10 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) { if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_DELETE); 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); int deleteCount = requirementMapper.deleteByIdAndStatus(id, fromStatus);
if (deleteCount != 1) { if (deleteCount != 1) {
@@ -345,6 +359,23 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
buildRequirementFieldChanges(requirement, null), null); 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
@@ -1035,6 +1066,124 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return respVO; return respVO;
} }
/**
* TD-016把项目需求 RespVO 列表上的 progressRate 字段批量回填。
* <p>口径:按 projectId 一次性算出本项目下所有需求的进度 map再递归扫 RespVO 树(含 children应用。
* 公式R.progressRate = AVG(R 自己挂的执行进度 R 的直接子需求进度),排除进度排除字典命中的执行状态;
* 当 pool 为空(无承接执行且无子需求)时返回 0.00。两位小数 HALF_UP。
*/
private void fillRequirementProgress(Long projectId, Collection<ProjectRequirementRespVO> respVOList) {
if (respVOList == null || respVOList.isEmpty() || projectId == null) {
return;
}
Map<Long, BigDecimal> progressMap = computeRequirementProgressMapByProjectId(projectId);
applyProgressRecursive(respVOList, progressMap);
}
private void applyProgressRecursive(Collection<ProjectRequirementRespVO> list, Map<Long, BigDecimal> 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&lt;requirementId, progressRate&gt;。
* 公式与 fillRequirementProgress 对齐:自下而上 DFSpool = 自己挂的执行平均 直接子需求进度。
*/
@VisibleForTesting
Map<Long, BigDecimal> computeRequirementProgressMapByProjectId(Long projectId) {
List<ProjectRequirementDO> allRequirements = requirementMapper.selectList(
new LambdaQueryWrapperX<ProjectRequirementDO>()
.eq(ProjectRequirementDO::getProjectId, projectId));
if (allRequirements.isEmpty()) {
return Collections.emptyMap();
}
Set<Long> requirementIds = allRequirements.stream()
.map(ProjectRequirementDO::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
// 一次 GROUP BY 拉到所有需求"自己挂的执行平均进度"
List<String> excludedStatusCodes = loadProgressExcludedExecutionStatusCodes();
Map<Long, BigDecimal> ownAvgMap = new HashMap<>();
List<Map<String, Object>> rows = projectExecutionMapper
.selectAvgProgressGroupByProjectRequirementIds(requirementIds, excludedStatusCodes);
if (rows != null) {
for (Map<String, Object> 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<Long, List<ProjectRequirementDO>> childrenIndex = allRequirements.stream()
.collect(Collectors.groupingBy(r -> r.getParentId() == null ? 0L : r.getParentId()));
// DFS 自下而上递归
Map<Long, BigDecimal> 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<Long, List<ProjectRequirementDO>> childrenIndex,
Map<Long, BigDecimal> ownAvgMap,
Map<Long, BigDecimal> result) {
if (result.containsKey(r.getId())) {
return result.get(r.getId());
}
List<BigDecimal> pool = new ArrayList<>();
BigDecimal ownAvg = ownAvgMap.get(r.getId());
if (ownAvg != null) {
pool.add(ownAvg);
}
List<ProjectRequirementDO> 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<String> loadProgressExcludedExecutionStatusCodes() {
List<String> 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<ProjectRequirementModuleRespVO> buildModuleTree(List<ProjectRequirementModuleDO> modules, Long parentId) { private List<ProjectRequirementModuleRespVO> buildModuleTree(List<ProjectRequirementModuleDO> modules, Long parentId) {
return modules.stream() return modules.stream()
.filter(module -> Objects.equals(module.getParentId(), parentId)) .filter(module -> Objects.equals(module.getParentId(), parentId))

View File

@@ -125,6 +125,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
innerReq.setKeyword(reqVO.getKeyword()); innerReq.setKeyword(reqVO.getKeyword());
innerReq.setParentTaskId(reqVO.getParentTaskId()); innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setOwnerId(reqVO.getOwnerId()); innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setPriority(reqVO.getPriority());
innerReq.setUpdateTime(reqVO.getUpdateTime()); innerReq.setUpdateTime(reqVO.getUpdateTime());
innerReq.setStatusCode(statusCode); innerReq.setStatusCode(statusCode);
return innerReq; return innerReq;

View File

@@ -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.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.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.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.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.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; 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); void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
/** /**
* 删除执行(软删 + CAS 状态校验 + 三重确认)。 * 删除执行(软删 + 状态校验 + 三重确认 + 级联软删)。
* <p>仅初始态 pending 可删;权限码 project:execution:delete 推荐挂"项目负责人"角色。 * <p>删除规则:状态非 completed已完成即可删除pending / active / paused / cancelled 全部允许;
* <p>不级联软删 rdms_execution_assignee / rdms_task与项目侧 deleteProject 风格一致,下挂记录通过 deleted=0 自然不可见)。 * 仅 completed 拒绝主动删除(已完成数据需保留作为审计与统计依据)。
* <p>三重确认reqVO 中 executionName执行名称、confirmText删除口令 DELETE 或 删除、reason删除原因三者均必填
* 任一缺失或不匹配直接拒绝reason 文本入审计日志。
* <p>级联范围:同步软删该执行下全部任务(含子孙任务)、任务工作日志、任务协办、执行协办。
* <p>软删机制:所有 mapper.deleteXxx 走 @TableLogic 自动转 UPDATE deleted=1下挂数据通过 deleted=0 过滤自然不可见。
* <p>权限:@CheckObjectPermission(objectType=PROJECT, permission=PERMISSION_DELETE) 对象域权限码,推荐挂"项目负责人"角色。
* <p>审计:写 EXECUTION_ACTION_DELETE 一条reason 入 audit_reason 字段。
*/ */
void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO); void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO);
/**
* 删除执行前的下挂数据预检。
* <p>返回执行下任务总数(含已软删之外的全部活跃任务)与 hasDependentData是否存在下挂标记。
* <p>前端依据该结果分流:无下挂走简化确认路径(仅口令 + 原因),有下挂走重型确认路径(额外提示级联影响范围)。
* <p>本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 {@link #deleteExecution} 负责。
*/
ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId);
void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO); void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO);
/** /**

View File

@@ -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.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.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.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.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.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; 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.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; 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.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.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO; 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.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants; 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.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.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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.execution.ProjectExecutionStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; 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.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; 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.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; 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.ProjectService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; 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.permission.VisibilityScopeResolver;
@@ -104,8 +110,16 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Resource
private ProjectService projectService; private ProjectService projectService;
@Resource @Resource
private ProjectRequirementService projectRequirementService;
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver; private VisibilityScopeResolver visibilityScopeResolver;
/** /**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。 * 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
@@ -121,23 +135,25 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
permission = ProjectExecutionConstants.PERMISSION_CREATE) permission = ProjectExecutionConstants.PERMISSION_CREATE)
public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) { public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
validateEditableProject(projectId); validateEditableProject(projectId);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName()); String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, null, executionName); validateExecutionNameUnique(projectId, null, executionName);
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId()); validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType); validateExecutionType(executionType);
validatePriority(reqVO.getPriority());
Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds()); Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
ProjectExecutionDO execution = new ProjectExecutionDO(); ProjectExecutionDO execution = new ProjectExecutionDO();
execution.setProjectId(projectId); execution.setProjectId(projectId);
execution.setProjectRequirementId(null); execution.setProjectRequirementId(reqVO.getProjectRequirementId());
execution.setExecutionName(executionName); execution.setExecutionName(executionName);
execution.setExecutionType(executionType); execution.setExecutionType(executionType);
execution.setOwnerId(reqVO.getOwnerId()); execution.setOwnerId(reqVO.getOwnerId());
String initialStatusCode = getInitialExecutionStatusCode(); String initialStatusCode = getInitialExecutionStatusCode();
execution.setStatusCode(initialStatusCode); execution.setStatusCode(initialStatusCode);
execution.setPriority(reqVO.getPriority());
execution.setPlannedStartDate(reqVO.getPlannedStartDate()); execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate()); execution.setPlannedEndDate(reqVO.getPlannedEndDate());
execution.setActualStartDate(null); execution.setActualStartDate(null);
@@ -163,18 +179,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateEditableProject(projectId); validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId()); ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId());
validateExecutionAllowEdit(execution); validateExecutionAllowEdit(execution);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName()); String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, execution.getId(), executionName); validateExecutionNameUnique(projectId, execution.getId(), executionName);
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType); validateExecutionType(executionType);
validatePriority(reqVO.getPriority());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 不再处理 ownerId换负责人必须走 /change-owner 端点spec §6.3 契约收口) // 不再处理 ownerId换负责人必须走 /change-owner 端点spec §6.3 契约收口)
ProjectExecutionDO before = cloneExecution(execution); ProjectExecutionDO before = cloneExecution(execution);
execution.setExecutionName(executionName); execution.setExecutionName(executionName);
execution.setExecutionType(executionType); execution.setExecutionType(executionType);
execution.setProjectRequirementId(null); execution.setPriority(reqVO.getPriority());
execution.setProjectRequirementId(reqVO.getProjectRequirementId());
execution.setPlannedStartDate(reqVO.getPlannedStartDate()); execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate()); execution.setPlannedEndDate(reqVO.getPlannedEndDate());
execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc())); execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc()));
@@ -214,6 +232,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId); boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
applyLifecycle(respVO, rootTasksAllCompleted); applyLifecycle(respVO, rootTasksAllCompleted);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId())); respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
fillProjectRequirementInfo(List.of(respVO));
return respVO; return respVO;
} }
@@ -226,6 +245,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return voPageResult; return voPageResult;
} }
fillExecutionProgress(projectId, list); fillExecutionProgress(projectId, list);
fillProjectRequirementInfo(list);
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 falsecomplete 按钮不下发。 // 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 falsecomplete 按钮不下发。
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list); Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
// 批量补负责人昵称,避免 N+1 // 批量补负责人昵称,避免 N+1
@@ -315,26 +335,48 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE) permission = ProjectExecutionConstants.PERMISSION_DELETE)
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) { public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
validateEditableProject(projectId); validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateDeleteConfirmText(reqVO.getConfirmText()); validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) { if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
} }
// 仅初始态可删(与项目侧 deleteProject 同款规则) // 删除路径不复用 validateEditableProject / 状态 allow_edit已完成执行用专门的硬卡拦截
String initialStatusCode = getInitialExecutionStatusCode(); // 其它非 completed 状态pending / active / paused / cancelled允许删除。
String fromStatus = execution.getStatusCode(); if (Objects.equals(execution.getStatusCode(), ProjectExecutionConstants.STATUS_COMPLETED)) {
if (!Objects.equals(fromStatus, initialStatusCode)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE);
} }
String reason = reqVO.getReason().trim(); String reason = reqVO.getReason().trim();
int deleteCount = projectExecutionMapper.deleteByIdAndStatus(executionId, fromStatus); String fromStatus = execution.getStatusCode();
// 级联软删先清子级worklog / task assignee / task再清同级协办最后软删执行本身
List<Long> 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) { if (deleteCount != 1) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
} }
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason); 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) { private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText); String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null if (normalizedConfirmText == null
@@ -420,13 +462,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return execution; return execution;
} }
@VisibleForTesting
void validateRequirementIdPhaseOne(Long projectRequirementId) {
if (projectRequirementId != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY);
}
}
@VisibleForTesting @VisibleForTesting
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) { void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即视为项目成员 // 多角色支持user 在项目内有任意 ACTIVE 角色即视为项目成员
@@ -497,6 +532,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID); 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 @VisibleForTesting
void validateDateRange(LocalDate startDate, LocalDate endDate, String message) { void validateDateRange(LocalDate startDate, LocalDate endDate, String message) {
if (startDate == null || endDate == null) { if (startDate == null || endDate == null) {
@@ -627,6 +676,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
valueOf(after, ProjectExecutionDO::getOwnerId)); valueOf(after, ProjectExecutionDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode), appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode),
valueOf(after, ProjectExecutionDO::getStatusCode)); valueOf(after, ProjectExecutionDO::getStatusCode));
appendFieldChange(fieldChanges, "priority",
valueOf(before, ProjectExecutionDO::getPriority),
valueOf(after, ProjectExecutionDO::getPriority));
appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate), appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate),
valueOf(after, ProjectExecutionDO::getPlannedStartDate)); valueOf(after, ProjectExecutionDO::getPlannedStartDate));
appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectExecutionDO::getPlannedEndDate), 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)))); list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
} }
/**
* 批量回填执行 RespVO 上的项目需求名称 / 状态编码TD-013
* 一次性 selectBatchIds 拉取所有出现过的 projectRequirementId避免列表场景 N+1。
*/
private void fillProjectRequirementInfo(Collection<ProjectExecutionRespVO> list) {
if (list == null || list.isEmpty()) {
return;
}
Set<Long> requirementIds = list.stream()
.map(ProjectExecutionRespVO::getProjectRequirementId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (requirementIds.isEmpty()) {
return;
}
Map<Long, ProjectRequirementDO> 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 取本页所有执行的一级任务平均进度。 * 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
* 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。 * 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task; package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; 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); void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/** /**
* 删除任务(软删 + CAS 状态校验 + 三重确认 + 执行 owner 字段硬卡)。 * 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。
* <p>仅初始态可删;权限码 project:task:delete 作菜单入口可见度;实际拦截以 execution.ownerId == currentUserId 为准spec §5.1 上级硬卡范式)。 * <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许);
* <p>不级联软删任务下挂的工时 / 协办人 / 子任务(通过 deleted=0 自然不可见) * 已 completed 的任务用专门错误码 PROJECT_TASK_NOT_ALLOW_DELETE 拦截
* <p>上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码 {@code project:task:delete}"删;
* 子任务由"父任务负责人 OR 项目负责人(权限码)"删。
* <p>三重确认:{@code taskName} / {@code confirmText}DELETE 或 删除)/ {@code reason} 全部必填,
* 任意一项缺失或不匹配即拒。
* <p>级联范围:软删该任务及其所有子孙任务、所有相关工作日志、所有相关任务协办;
* 执行 / 项目需求 progressRate 是读时聚合,无需主动刷新。
* <p>父进度回算:删完后若有 parent_task_id触发 {@code recalcParentProgressFrom} 把祖先链路进度重算。
* <p>审计:写一条 {@code TASK_ACTION_DELETE} 审计记录reason 入审计上下文。
*/ */
void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO); void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO);
/**
* 任务删除前的下挂数据预检:返回子任务数 + 工作日志数 + hasDependentData 标记。
* <p>前端依据该结果分流:无下挂走简化确认路径,有下挂走重型确认路径。
* <p>本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 deleteTask 负责。
*/
ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId);
/** /**
* 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。 * 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。
* 由工时模块在 owner 维度的 worklog create/update/delete 后调用。 * 由工时模块在 owner 维度的 worklog create/update/delete 后调用。

View File

@@ -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.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants; 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.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; 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.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; 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.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.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO; 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.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; 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.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.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper; 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.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; 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.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.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService; 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.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -83,12 +90,16 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private ExecutionAssigneeMapper executionAssigneeMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
private TaskWorklogMapper taskWorklogMapper; private TaskWorklogMapper taskWorklogMapper;
@Resource @Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Resource
private ProjectTaskStatusLogMapper projectTaskStatusLogMapper; private ProjectTaskStatusLogMapper projectTaskStatusLogMapper;
@Resource @Resource
private BizAuditLogMapper bizAuditLogMapper; private BizAuditLogMapper bizAuditLogMapper;
@@ -114,6 +125,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private ProjectObjectAuthorizationService projectObjectAuthorizationService; private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource @Resource
private VisibilityScopeResolver visibilityScopeResolver; private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private DictDataApi dictDataApi;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -131,6 +144,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录 // 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录
validateLeafToParentSplit(parentTask); validateLeafToParentSplit(parentTask);
validatePriority(reqVO.getPriority());
AttachmentValidator.validate(reqVO.getAttachments()); AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments());
@@ -142,6 +156,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空")); task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId); task.setOwnerId(ownerId);
task.setStatusCode(getInitialTaskStatusCode()); task.setStatusCode(getInitialTaskStatusCode());
task.setPriority(reqVO.getPriority());
// 任务进度统一由 worklog 驱动;新建任务强制为 0 // 任务进度统一由 worklog 驱动;新建任务强制为 0
task.setProgressRate(BigDecimal.ZERO); task.setProgressRate(BigDecimal.ZERO);
task.setPlannedStartDate(reqVO.getPlannedStartDate()); task.setPlannedStartDate(reqVO.getPlannedStartDate());
@@ -196,6 +211,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (!Objects.equals(oldParentId, newParentId)) { if (!Objects.equals(oldParentId, newParentId)) {
validateLeafToParentSplit(parentTask); validateLeafToParentSplit(parentTask);
} }
validatePriority(reqVO.getPriority());
AttachmentValidator.validate(reqVO.getAttachments()); AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments());
// 任务进度由 worklog 驱动owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参 // 任务进度由 worklog 驱动owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参
@@ -205,6 +221,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空")); task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId); task.setOwnerId(ownerId);
task.setPriority(reqVO.getPriority());
task.setPlannedStartDate(reqVO.getPlannedStartDate()); task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate()); task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护 // 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护
@@ -229,35 +246,86 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) { public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
validateEditableProject(projectId); validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删 // 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删
Long upperOwnerId = task.getParentTaskId() == null requireDeletePermissionOnTask(projectId, task, execution);
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE);
validateDeleteConfirmText(reqVO.getConfirmText()); validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) { if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH); throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
} }
// 仅初始态可 // 删除路径不复用 validateEditableProject / validateExecutionAllowEdit已完成任务用专门的硬卡拦截
String fromStatus = task.getStatusCode(); // 其它非 completed 状态pending / active / paused / cancelled允许删除。
ObjectStatusModelDO statusModel = objectStatusModelMapper if (Objects.equals(task.getStatusCode(), ProjectTaskConstants.STATUS_COMPLETED)) {
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE); throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE);
} }
String reason = reqVO.getReason().trim(); String reason = reqVO.getReason().trim();
int deleteCount = projectTaskMapper.deleteByIdAndStatus(taskId, fromStatus); String fromStatus = task.getStatusCode();
if (deleteCount != 1) { Long parentTaskId = task.getParentTaskId();
// 收集自身 + 子孙任务 id级联软删工作日志 / 协办人 / 任务
List<Long> 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); throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
} }
// 父任务进度回算(执行 / 项目需求 progressRate 是读时聚合,无需主动刷新)
if (parentTaskId != null) {
recalcParentProgressFrom(parentTaskId);
}
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); 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 项目负责人(权限码)操作。
* <p>同时被 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) { private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText); String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null if (normalizedConfirmText == null
@@ -398,6 +466,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId()); ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId()); respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
} }
fillProjectRequirementInfo(List.of(respVO), execution);
return respVO; return respVO;
} }
@@ -442,6 +511,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
// 执行 owner 单条查询整页共享URL 路径定 executionId // 执行 owner 单条查询整页共享URL 路径定 executionId
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId(); Long executionOwnerId = execution == null ? null : execution.getOwnerId();
fillProjectRequirementInfo(list, execution);
list.forEach(vo -> { list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of()); List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
@@ -465,6 +535,28 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
return voPageResult; return voPageResult;
} }
/**
* 把任务 RespVO 上的项目需求信息TD-013回填。
* <p>本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage
* 故整页 task 共享同一 execution单次 selectById 即可拿到 ProjectRequirementDO无 N+1。
* 若 execution 缺失或未关联项目需求则不回填。
*/
private void fillProjectRequirementInfo(Collection<ProjectTaskRespVO> 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<ProjectTaskRespVO.TaskAssigneeView> buildAssigneeViews(List<TaskAssigneeDO> activeList) { private List<ProjectTaskRespVO.TaskAssigneeView> buildAssigneeViews(List<TaskAssigneeDO> activeList) {
if (activeList == null || activeList.isEmpty()) { if (activeList == null || activeList.isEmpty()) {
return List.of(); return List.of();
@@ -970,6 +1062,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
valueOf(after, ProjectTaskDO::getOwnerId)); valueOf(after, ProjectTaskDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode), appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode),
valueOf(after, ProjectTaskDO::getStatusCode)); valueOf(after, ProjectTaskDO::getStatusCode));
appendFieldChange(fieldChanges, "priority",
valueOf(before, ProjectTaskDO::getPriority),
valueOf(after, ProjectTaskDO::getPriority));
appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate), appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate),
valueOf(after, ProjectTaskDO::getProgressRate)); valueOf(after, ProjectTaskDO::getProgressRate));
appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectTaskDO::getPlannedStartDate), 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。 * 从给定父任务向上递归刷新进度:父进度 = AVG(直接子.progressRate)scale=2 HALF_UP。
* 终止条件parentTaskId 为 null已到根或当前层进度未发生变化截断进一步刷新 * 终止条件parentTaskId 为 null已到根或当前层进度未发生变化截断进一步刷新

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.service.project.task.worklog; 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.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; 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.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; 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.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.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; 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.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; 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.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -64,6 +67,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Resource @Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource @Resource
private DictDataApi dictDataApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
/** /**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。 * 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
@@ -105,6 +110,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
validateDurationGranularity(reqVO.getDurationHours()); validateDurationGranularity(reqVO.getDurationHours());
validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null); validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null);
validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null); validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null);
validateDifficulty(reqVO.getDifficulty());
AttachmentValidator.validate(reqVO.getAttachments()); AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(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()); validateNoOverlap(taskId, worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId());
validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId()); validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId());
validateDifficulty(reqVO.getDifficulty());
AttachmentValidator.validate(reqVO.getAttachments()); AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments()); attachmentFileIdResolver.resolve(reqVO.getAttachments());
@@ -148,6 +155,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
update.setEndDate(reqVO.getEndDate()); update.setEndDate(reqVO.getEndDate());
update.setDurationHours(reqVO.getDurationHours()); update.setDurationHours(reqVO.getDurationHours());
update.setProgressRate(reqVO.getProgressRate()); update.setProgressRate(reqVO.getProgressRate());
update.setDifficulty(reqVO.getDifficulty());
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments()); update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update); taskWorklogMapper.updateById(update);
@@ -210,6 +218,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
worklog.setEndDate(reqVO.getEndDate()); worklog.setEndDate(reqVO.getEndDate());
worklog.setDurationHours(reqVO.getDurationHours()); worklog.setDurationHours(reqVO.getDurationHours());
worklog.setProgressRate(reqVO.getProgressRate()); worklog.setProgressRate(reqVO.getProgressRate());
worklog.setDifficulty(reqVO.getDifficulty());
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
worklog.setAttachments(reqVO.getAttachments()); worklog.setAttachments(reqVO.getAttachments());
worklog.setDifficulty(reqVO.getDifficulty()); worklog.setDifficulty(reqVO.getDifficulty());
@@ -395,6 +404,24 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
return nicknameMap; 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) { private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) { if (!StringUtils.hasText(value)) {
return null; return null;

View File

@@ -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<Long, BigDecimal> 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<Long, BigDecimal> 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<Long, BigDecimal> 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<Long, BigDecimal> 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<Long, BigDecimal> 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<String, Object> rowOf(Long requirementId, String avg) {
Map<String, Object> row = new HashMap<>();
row.put("projectRequirementId", requirementId);
row.put("progressRate", new BigDecimal(avg));
return row;
}
}

View File

@@ -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.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; 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.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.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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.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.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; 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.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; 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.anyCollection;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@@ -96,9 +99,14 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
private ProjectExecutionAssigneeService projectExecutionAssigneeService; private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Mock @Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver; private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
@Mock
private ProjectRequirementService projectRequirementService;
@Mock
private ProjectRequirementMapper projectRequirementMapper;
/** /**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。 * 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
* 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true既有测试不因 priority 校验失败。
* 真正需要测试 scope 行为的用例可在方法内显式覆盖。 * 真正需要测试 scope 行为的用例可在方法内显式覆盖。
*/ */
@BeforeEach @BeforeEach
@@ -107,6 +115,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all()); .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any())) lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all()); .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
.thenReturn(success(true));
} }
@Test @Test
@@ -154,8 +164,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class)); verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class));
} }
/**
* TD-013传入非空 projectRequirementId 时,必须调用 ProjectRequirementService.validateUsableForExecution
* 校验抛错时执行模块应原样透传错误码(不存在 / 不属本项目 / 终态三类内部分支由项目需求模块自测)。
*/
@Test @Test
void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() { void createExecution_whenRequirementValidationFails_shouldPropagateError() {
Long projectId = 2001L; Long projectId = 2001L;
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO(); ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
@@ -165,11 +179,54 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createProjectStatus("pending", true)); .thenReturn(createProjectStatus("pending", true));
doThrow(new ServiceException(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL))
.when(projectRequirementService).validateUsableForExecution(projectId, 9001L);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.createExecution(projectId, reqVO)); () -> 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<ProjectExecutionDO> executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class);
verify(projectExecutionMapper).insert(executionCaptor.capture());
assertEquals(9001L, executionCaptor.getValue().getProjectRequirementId());
verify(projectRequirementService).validateUsableForExecution(projectId, 9001L);
} }
@Test @Test
@@ -203,6 +260,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("feature"); reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setPriority("1");
reqVO.setAssigneeUserIds(List.of()); reqVO.setAssigneeUserIds(List.of());
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
@@ -229,6 +287,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setId(executionId); reqVO.setId(executionId);
reqVO.setExecutionName("接口联调-修订"); reqVO.setExecutionName("接口联调-修订");
reqVO.setExecutionType("feature"); reqVO.setExecutionType("feature");
reqVO.setPriority("1");
reqVO.setProjectRequirementId(null); reqVO.setProjectRequirementId(null);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
@@ -292,6 +351,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start")) when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start"))
.thenReturn(createTransition("start", "active", false)); .thenReturn(createTransition("start", "active", false));
when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(1); when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(1);
// maybeFillActualDatesfromStatus="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); projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO);
@@ -312,6 +376,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() { void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() {
Long projectId = 2001L; Long projectId = 2001L;
Long executionId = 5001L; Long executionId = 5001L;
// ownerId=3001Lcancel 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
reqVO.setActionCode("cancel"); reqVO.setActionCode("cancel");
@@ -322,11 +387,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel")) when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true)); .thenReturn(createTransition("cancel", "cancelled", true));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
} }
}
@Test @Test
void changeExecutionStatus_whenConcurrentModified_shouldThrow() { void changeExecutionStatus_whenConcurrentModified_shouldThrow() {
@@ -352,6 +418,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
void changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow() { void changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow() {
Long projectId = 2001L; Long projectId = 2001L;
Long executionId = 5001L; Long executionId = 5001L;
// ownerId=3001Lcomplete 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
execution.setStatusCode("active"); execution.setStatusCode("active");
ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO(); ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
@@ -362,11 +429,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete")) when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete"))
.thenReturn(null); .thenReturn(null);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
} }
}
@Test @Test
void updateExecution_whenExecutionPaused_shouldThrowNotAllowEdit() { void updateExecution_whenExecutionPaused_shouldThrowNotAllowEdit() {
@@ -594,6 +662,148 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(0L, result.getTotal()); 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<ProjectExecutionDO> 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<BizAuditLogDO> 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) { private ProjectDO createEditableProject(Long projectId) {
ProjectDO project = new ProjectDO(); ProjectDO project = new ProjectDO();
project.setId(projectId); project.setId(projectId);
@@ -664,4 +874,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
user.setNickname(nickname); user.setNickname(nickname);
return user; return user;
} }
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
} }

View File

@@ -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());
}
}

View File

@@ -103,9 +103,9 @@ public class DictDataController {
} }
@GetMapping("/page") @GetMapping("/page")
@Operation(summary = "获得字典类型的分页") @Operation(summary = "获得字典数据的分页")
@PreAuthorize("@ss.hasPermission('system:dict:query')") @PreAuthorize("@ss.hasPermission('system:dict:query')")
public CommonResult<PageResult<DictDataRespVO>> getDictTypePage(@Valid DictDataPageReqVO pageReqVO) { public CommonResult<PageResult<DictDataRespVO>> getDictDataPage(@Valid DictDataPageReqVO pageReqVO) {
PageResult<DictDataDO> pageResult = dictDataService.getDictDataPage(pageReqVO); PageResult<DictDataDO> pageResult = dictDataService.getDictDataPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, DictDataRespVO.class)); return success(BeanUtils.toBean(pageResult, DictDataRespVO.class));
} }

View File

@@ -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.pojo.PageParam;
import com.njcn.rdms.framework.common.validation.InEnum; import com.njcn.rdms.framework.common.validation.InEnum;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - 字典类型分页列表 Request VO") @Schema(description = "管理后台 - 字典数据分页列表 Request VO")
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DictDataPageReqVO extends PageParam { public class DictDataPageReqVO extends PageParam {
@@ -17,8 +18,9 @@ public class DictDataPageReqVO extends PageParam {
@Size(max = 100, message = "字典标签长度不能超过100个字符") @Size(max = 100, message = "字典标签长度不能超过100个字符")
private String label; private String label;
@Schema(description = "字典类型,模糊匹配", example = "sys_common_sex") @Schema(description = "字典类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "sys_common_sex")
@Size(max = 100, message = "字典类型类型长度不能超过100个字符") @NotBlank(message = "字典类型不能为空")
@Size(max = 100, message = "字典类型长度不能超过100个字符")
private String dictType; private String dictType;
@Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1") @Schema(description = "展示状态,参见 CommonStatusEnum 枚举类", example = "1")

View File

@@ -28,4 +28,7 @@ public class DictDataSimpleRespVO {
@Schema(description = "css 样式", example = "btn-visible") @Schema(description = "css 样式", example = "btn-visible")
private String cssClass; private String cssClass;
@Schema(description = "备注", example = "仅内部使用")
private String remark;
} }

View File

@@ -8,7 +8,6 @@ import com.njcn.rdms.module.system.dal.dataobject.dict.DictDataDO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -37,7 +36,8 @@ public interface DictDataMapper extends BaseMapperX<DictDataDO> {
.likeIfPresent(DictDataDO::getLabel, reqVO.getLabel()) .likeIfPresent(DictDataDO::getLabel, reqVO.getLabel())
.eqIfPresent(DictDataDO::getDictType, reqVO.getDictType()) .eqIfPresent(DictDataDO::getDictType, reqVO.getDictType())
.eqIfPresent(DictDataDO::getStatus, reqVO.getStatus()) .eqIfPresent(DictDataDO::getStatus, reqVO.getStatus())
.orderByDesc(Arrays.asList(DictDataDO::getDictType, DictDataDO::getSort))); .orderByAsc(DictDataDO::getSort)
.orderByAsc(DictDataDO::getId));
} }
default List<DictDataDO> selectListByStatusAndDictType(Integer status, String dictType) { default List<DictDataDO> selectListByStatusAndDictType(Integer status, String dictType) {

View File

@@ -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<Wrapper<DictDataDO>> 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());
}
}