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

@@ -123,6 +123,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行协办人已失效");
// 保留TD-013 解锁后业务路径已不会再触发,预留用于灰度回滚关闭关联能力
ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行");
ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接");
@@ -135,9 +136,13 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_EXECUTION_ASSIGNEE_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行协办人");
ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行【{}】动作");
ErrorCode PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "仅初始态(待开始)的执行允许删除");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "已完成的执行允许删除");
ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致");
ErrorCode PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_003_021, "删除确认口令必须为 DELETE 或 删除");
ErrorCode PROJECT_EXECUTION_PRIORITY_INVALID = new ErrorCode(1_008_003_022, "执行优先级不是有效字典值");
ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_003_023, "关联的项目需求不存在或已删除");
ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_BELONG_TO_PROJECT = new ErrorCode(1_008_003_024, "关联的项目需求不属于当前项目");
ErrorCode PROJECT_EXECUTION_REQUIREMENT_TERMINAL = new ErrorCode(1_008_003_025, "项目需求已处于终态,不允许关联新执行");
// ========== 任务管理 1-008-004-000 ==========
ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在");
@@ -151,9 +156,10 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_TASK_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_008, "当前任务状态不允许维护任务");
ErrorCode PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED = new ErrorCode(1_008_004_010, "父任务完成前,子任务必须全部完成或取消");
ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作");
ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "仅初始态(待开始)的任务允许删除");
ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "已完成的任务允许删除");
ErrorCode PROJECT_TASK_DELETE_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致");
ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除");
ErrorCode PROJECT_TASK_PRIORITY_INVALID = new ErrorCode(1_008_004_015, "任务优先级不是有效字典值");
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零");
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录");
@@ -175,6 +181,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID = new ErrorCode(1_008_006_007, "段起始日期不能晚于段结束日期");
ErrorCode PROJECT_TASK_WORKLOG_DATE_OVERLAP = new ErrorCode(1_008_006_008, "日期范围与该任务下您已有的工时记录重叠");
ErrorCode PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC = new ErrorCode(1_008_006_010, "工时进度与日期顺序不一致:早段进度不得高于晚段、晚段进度不得低于早段");
ErrorCode PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID = new ErrorCode(1_008_006_011, "完成难度不在字典范围内");
// ========== 任务 / 工时附件 1_008_007_xxx ==========
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个");
@@ -201,6 +208,7 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_CHILDREN = new ErrorCode(1_008_007_015, "存在子模块,请先删除子模块");
ErrorCode PROJECT_REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_007_016, "模块下存在项目需求,请先删除需求");
ErrorCode PROJECT_REQUIREMENT_CHILD_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_017, "只有不存在子需求,或子需求都处于已取消和已拒绝状态时,父需求才允许取消");
ErrorCode PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE = new ErrorCode(1_008_007_018, "该项目需求下存在承接执行,请先解绑或转移");
ErrorCode PROJECT_REQUIREMENT_SYNCED_FROM_PRODUCT_NOT_ALLOW_CANCEL = new ErrorCode(1_008_007_019, "\u7531\u4ea7\u54c1\u9700\u6c42\u6d41\u8f6c\u751f\u6210\u7684\u9879\u76ee\u9700\u6c42\u4e0d\u5141\u8bb8\u53d6\u6d88");
// ========== 个人事项 1_008_008_xxx ==========

View File

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

View File

@@ -51,6 +51,12 @@ public final class ProjectExecutionConstants {
*/
public static final String PERMISSION_DELETE = "project:execution:delete";
/**
* 执行"已完成"状态码,对应 rdms_object_status_model 中 object_type='execution' 且 status_code='completed' 的状态。
* 删除时拒绝主动删除(已完成的执行不允许删除)。
*/
public static final String STATUS_COMPLETED = "completed";
/**
* 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
* 校验时精确匹配trim 后比对)。

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

View File

@@ -29,11 +29,17 @@ public class ProjectExecutionCreateReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotBlank(message = "优先级不能为空")
@Size(max = 8, message = "优先级长度不能超过8个字符")
private String priority;
@Schema(description = "执行负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001")
@NotNull(message = "执行负责人不能为空")
private Long ownerId;
@Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001")
@Schema(description = "关联项目需求编号,可选;不传或传 null 表示无主执行。传入值必须属于本项目且需求非终态", example = "9001")
private Long projectRequirementId;
@Schema(description = "计划开始日期")

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个字符")
private String statusCode;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) ~ 3=P3(最低)", example = "0")
@Size(max = 8)
private String priority;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;

View File

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

View File

@@ -30,7 +30,13 @@ public class ProjectExecutionUpdateReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType;
@Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001")
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotBlank(message = "优先级不能为空")
@Size(max = 8, message = "优先级长度不能超过8个字符")
private String priority;
@Schema(description = "关联项目需求编号。PUT 全字段语义:传 null=解绑,传值=更新关联。传入值必须属于本项目且需求非终态", example = "9001")
private Long projectRequirementId;
@Schema(description = "计划开始日期")

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.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
@@ -94,8 +95,17 @@ public class ProjectTaskController {
return success(true);
}
@GetMapping("/{taskId}/delete-precheck")
@Operation(summary = "任务删除预检:返回下挂子任务数 + 工作日志数 + 是否需要重型确认")
public CommonResult<ProjectTaskDeletePrecheckRespVO> precheckDeleteTask(
@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId) {
return success(projectTaskService.precheckDeleteTask(projectId, executionId, taskId));
}
@DeleteMapping("/{taskId}")
@Operation(summary = "删除任务(仅初始态可删,三重确认 + 执行 owner 硬卡)")
@Operation(summary = "删除任务(已完成态禁止,三重确认 + 上级 owner 硬卡)")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
@@ -25,6 +26,10 @@ public class ProjectTaskBoardPageReqVO extends PageParam {
example = "[\"pending\",\"active\"]")
private String[] statusCode;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) ~ 3=P3(最低)", example = "0")
@Size(max = 8)
private String priority;
@Schema(description = "关键词,匹配任务标题", example = "联调")
private String keyword;

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个字符")
private String statusCode;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) ~ 3=P3(最低)", example = "0")
@Size(max = 8)
private String priority;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;

View File

@@ -21,6 +21,12 @@ public class ProjectTaskRespVO {
private Long projectId;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId;
@Schema(description = "所属执行关联的项目需求编号service 层批量回填)")
private Long projectRequirementId;
@Schema(description = "项目需求名称service 层批量回填,避免 N+1", example = "前端联调-需求A")
private String projectRequirementName;
@Schema(description = "项目需求状态编码service 层批量回填)", example = "implementing")
private String projectRequirementStatusCode;
@Schema(description = "父任务编号")
private Long parentTaskId;
@Schema(description = "父任务负责人用户编号;一级任务为 null子任务用于前端判断"
@@ -41,6 +47,10 @@ public class ProjectTaskRespVO {
private String statusCode;
@Schema(description = "任务状态名称", example = "待开始")
private String statusName;
@Schema(description = "优先级编码(字典 rdms_req_priority0=P0(最高) ~ 3=P3(最低)", example = "0")
private String priority;
@Schema(description = "优先级名称(字典 label后端不填、前端按字典 cache 自译)", example = "P0")
private String priorityName;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "当前状态是否允许编辑", example = "true")

View File

@@ -44,6 +44,12 @@ public class ProjectTaskSaveReqVO {
@Size(max = 200000, message = "任务说明长度不能超过200000个字符")
private String taskDesc;
@Schema(description = "优先级字典 rdms_req_priority 值0=P0(最高) / 1=P1 / 2=P2 / 3=P3(最低)",
requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotBlank(message = "优先级不能为空")
@Size(max = 8, message = "优先级长度不能超过8个字符")
private String priority;
@Schema(description = "初始协办人用户编号列表;仅在创建任务时生效,编辑任务时静默忽略。"
+ "协办人通过独立接口管理,详见 /tasks/{id}/assignees")
private List<Long> assigneeUserIds;

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -46,6 +47,12 @@ public class TaskWorklogSaveReqVO {
@DecimalMax(value = "100.00", message = "本次填报进度不能大于 100")
private BigDecimal progressRate;
@Schema(description = "完成难度字典 rdms_worklog_difficulty 值1=简单 / 2=一般 / 3=困难 / 4=超难",
requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@NotBlank(message = "完成难度不能为空")
@Size(max = 8, message = "完成难度长度不能超过 8 个字符")
private String difficulty;
@Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试")
@Size(max = 2000, message = "工作内容长度不能超过 2000 个字符")
private String workContent;
@@ -53,7 +60,4 @@ public class TaskWorklogSaveReqVO {
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)")
@Valid
private List<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 lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -96,4 +97,11 @@ public class ProjectRequirementRespVO {
@Schema(description = "是否为终态", example = "false")
private Boolean terminal;
@Schema(description = "需求进度TD-016 读时聚合service 层批量计算)。"
+ "公式AVG(该需求自己承接的执行进度 直接子需求进度)"
+ "排除 rdms_object_status_model.progress_excluded_flag=1 的执行状态(当前为 cancelled"
+ "无任何执行且无子需求时返回 0.00。两位小数HALF_UP。",
example = "0.65")
private BigDecimal progressRate;
}

View File

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

View File

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

View File

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

View File

@@ -67,4 +67,15 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 按 execution_id 批量软删执行协办(含已 removed 的历史段)。
*/
default int deleteByExecutionId(Long executionId) {
if (executionId == null) {
return 0;
}
return delete(new LambdaQueryWrapperX<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.service.project.permission.VisibilityScope;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO> {
@@ -35,14 +39,16 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return PageResult.empty();
}
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId)
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProjectExecutionDO::getId);
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(ProjectExecutionDO::getProjectId, projectId);
queryWrapper.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType());
queryWrapper.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectExecutionDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectExecutionDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
@@ -106,6 +112,40 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
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) {
ProjectExecutionDO update = new ProjectExecutionDO();
update.setStatusCode(toStatus);

View File

@@ -43,8 +43,10 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId());
queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectTaskDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByAsc(ProjectTaskDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
@@ -319,4 +321,62 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
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));
}
/**
* 按 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) {
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId());
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId())
.eqIfPresent(TaskWorklogDO::getDifficulty, reqVO.getDifficulty());
if (reqVO.getEndDate() != null) {
queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate());
}
@@ -165,4 +166,26 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
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();
/**
* 校验项目需求可被执行关联。
* <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.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementAllowedTransitionBatchRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementBatchReqVO;
@@ -35,6 +36,7 @@ import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLo
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
@@ -46,6 +48,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -120,6 +124,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
private ObjectStatusModelMapper statusModelMapper;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -199,7 +205,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
public ProjectRequirementRespVO getRequirement(Long id, Long projectId) {
ProjectRequirementDO requirement = validateRequirementExists(id);
validateRequirementBelongsToProject(requirement, projectId);
return buildRequirementRespVO(requirement);
ProjectRequirementRespVO respVO = buildRequirementRespVO(requirement);
fillRequirementProgress(projectId, List.of(respVO));
return respVO;
}
@Override
@@ -220,6 +228,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
List<ProjectRequirementRespVO> list = pageResult.getList().stream()
.map(requirement -> buildRequirementRespVO(requirement, statusModelMap))
.collect(Collectors.toList());
fillRequirementProgress(pageReqVO.getProjectId(), list);
return new PageResult<>(list, pageResult.getTotal());
}
@@ -274,6 +283,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
List<ProjectRequirementRespVO> list = pagedRootRequirements.stream()
.map(requirement -> buildRequirementRespVOWithPathChildren(requirement, pathNodeIds, childrenMap, statusModelMap))
.collect(Collectors.toList());
fillRequirementProgress(projectId, list);
return new PageResult<>(list, (long) total);
}
@@ -336,6 +346,10 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_DELETE);
}
// TD-013 口径 Q4=A项目需求下有承接执行时禁止删除需先解绑或转移
if (projectExecutionMapper.countActiveByProjectRequirementId(id) > 0) {
throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_HAS_EXECUTIONS_NOT_ALLOW_DELETE);
}
int deleteCount = requirementMapper.deleteByIdAndStatus(id, fromStatus);
if (deleteCount != 1) {
@@ -345,6 +359,23 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
buildRequirementFieldChanges(requirement, null), null);
}
@Override
public void validateUsableForExecution(Long projectId, Long projectRequirementId) {
if (projectRequirementId == null) {
return;
}
ProjectRequirementDO requirement = requirementMapper.selectById(projectRequirementId);
if (requirement == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_EXISTS);
}
if (!Objects.equals(requirement.getProjectId(), projectId)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_BELONG_TO_PROJECT);
}
if (TERMINAL_STATUSES.contains(requirement.getStatusCode())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId",
@@ -1035,6 +1066,124 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
return respVO;
}
/**
* TD-016把项目需求 RespVO 列表上的 progressRate 字段批量回填。
* <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) {
return modules.stream()
.filter(module -> Objects.equals(module.getParentId(), parentId))

View File

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

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.ProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
@@ -40,12 +41,26 @@ public interface ProjectExecutionService {
void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
/**
* 删除执行(软删 + CAS 状态校验 + 三重确认)。
* <p>仅初始态 pending 可删;权限码 project:execution:delete 推荐挂"项目负责人"角色。
* <p>不级联软删 rdms_execution_assignee / rdms_task与项目侧 deleteProject 风格一致,下挂记录通过 deleted=0 自然不可见)。
* 删除执行(软删 + 状态校验 + 三重确认 + 级联软删)。
* <p>删除规则:状态非 completed已完成即可删除pending / active / paused / cancelled 全部允许;
* 仅 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);
/**
* 删除执行前的下挂数据预检。
* <p>返回执行下任务总数(含已软删之外的全部活跃任务)与 hasDependentData是否存在下挂标记。
* <p>前端依据该结果分流:无下挂走简化确认路径(仅口令 + 原因),有下挂走重型确认路径(额外提示级联影响范围)。
* <p>本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 {@link #deleteExecution} 负责。
*/
ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId);
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.ProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
@@ -28,15 +30,19 @@ import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
@@ -104,8 +110,16 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Resource
private ProjectService projectService;
@Resource
private ProjectRequirementService projectRequirementService;
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
/**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
@@ -121,23 +135,25 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
permission = ProjectExecutionConstants.PERMISSION_CREATE)
public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
validateEditableProject(projectId);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, null, executionName);
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType);
validatePriority(reqVO.getPriority());
Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
ProjectExecutionDO execution = new ProjectExecutionDO();
execution.setProjectId(projectId);
execution.setProjectRequirementId(null);
execution.setProjectRequirementId(reqVO.getProjectRequirementId());
execution.setExecutionName(executionName);
execution.setExecutionType(executionType);
execution.setOwnerId(reqVO.getOwnerId());
String initialStatusCode = getInitialExecutionStatusCode();
execution.setStatusCode(initialStatusCode);
execution.setPriority(reqVO.getPriority());
execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate());
execution.setActualStartDate(null);
@@ -163,18 +179,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId());
validateExecutionAllowEdit(execution);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, execution.getId(), executionName);
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType);
validatePriority(reqVO.getPriority());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 不再处理 ownerId换负责人必须走 /change-owner 端点spec §6.3 契约收口)
ProjectExecutionDO before = cloneExecution(execution);
execution.setExecutionName(executionName);
execution.setExecutionType(executionType);
execution.setProjectRequirementId(null);
execution.setPriority(reqVO.getPriority());
execution.setProjectRequirementId(reqVO.getProjectRequirementId());
execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate());
execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc()));
@@ -214,6 +232,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
applyLifecycle(respVO, rootTasksAllCompleted);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
fillProjectRequirementInfo(List.of(respVO));
return respVO;
}
@@ -226,6 +245,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return voPageResult;
}
fillExecutionProgress(projectId, list);
fillProjectRequirementInfo(list);
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 falsecomplete 按钮不下发。
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
// 批量补负责人昵称,避免 N+1
@@ -315,26 +335,48 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
validateEditableProject(projectId);
validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
}
// 仅初始态可删(与项目侧 deleteProject 同款规则)
String initialStatusCode = getInitialExecutionStatusCode();
String fromStatus = execution.getStatusCode();
if (!Objects.equals(fromStatus, initialStatusCode)) {
// 删除路径不复用 validateEditableProject / 状态 allow_edit已完成执行用专门的硬卡拦截
// 其它非 completed 状态pending / active / paused / cancelled允许删除。
if (Objects.equals(execution.getStatusCode(), ProjectExecutionConstants.STATUS_COMPLETED)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE);
}
String reason = reqVO.getReason().trim();
int deleteCount = projectExecutionMapper.deleteByIdAndStatus(executionId, fromStatus);
String fromStatus = execution.getStatusCode();
// 级联软删先清子级worklog / task assignee / task再清同级协办最后软删执行本身
List<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) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
}
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
}
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
int taskCount = projectTaskMapper.countByExecutionId(executionId);
ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO();
respVO.setTaskCount(taskCount);
respVO.setHasDependentData(taskCount > 0);
return respVO;
}
private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null
@@ -420,13 +462,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return execution;
}
@VisibleForTesting
void validateRequirementIdPhaseOne(Long projectRequirementId) {
if (projectRequirementId != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY);
}
}
@VisibleForTesting
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
// 多角色支持user 在项目内有任意 ACTIVE 角色即视为项目成员
@@ -497,6 +532,20 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID);
}
@VisibleForTesting
void validatePriority(String priority) {
try {
Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.REQ_PRIORITY,
java.util.List.of(priority.trim())).getCheckedData();
if (Boolean.TRUE.equals(valid)) {
return;
}
} catch (RuntimeException ex) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID);
}
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID);
}
@VisibleForTesting
void validateDateRange(LocalDate startDate, LocalDate endDate, String message) {
if (startDate == null || endDate == null) {
@@ -627,6 +676,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
valueOf(after, ProjectExecutionDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode),
valueOf(after, ProjectExecutionDO::getStatusCode));
appendFieldChange(fieldChanges, "priority",
valueOf(before, ProjectExecutionDO::getPriority),
valueOf(after, ProjectExecutionDO::getPriority));
appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate),
valueOf(after, ProjectExecutionDO::getPlannedStartDate));
appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectExecutionDO::getPlannedEndDate),
@@ -746,6 +798,33 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
}
/**
* 批量回填执行 RespVO 上的项目需求名称 / 状态编码TD-013
* 一次性 selectBatchIds 拉取所有出现过的 projectRequirementId避免列表场景 N+1。
*/
private void fillProjectRequirementInfo(Collection<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 取本页所有执行的一级任务平均进度。
* 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
@@ -42,12 +43,27 @@ public interface ProjectTaskService {
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**
* 删除任务(软删 + CAS 状态校验 + 三重确认 + 执行 owner 字段硬卡)。
* <p>仅初始态可删;权限码 project:task:delete 作菜单入口可见度;实际拦截以 execution.ownerId == currentUserId 为准spec §5.1 上级硬卡范式)。
* <p>不级联软删任务下挂的工时 / 协办人 / 子任务(通过 deleted=0 自然不可见)
* 删除任务(软删 + 三重确认 + 上级硬卡 + 级联软删 + 父进度回算 + 审计)。
* <p>删除规则:非 completed 即可删pending / active / paused / cancelled 全允许);
* 已 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);
/**
* 任务删除前的下挂数据预检:返回子任务数 + 工作日志数 + hasDependentData 标记。
* <p>前端依据该结果分流:无下挂走简化确认路径,有下挂走重型确认路径。
* <p>本接口为只读预检,不变更任何数据;删除规则与级联范围的最终校验仍由 deleteTask 负责。
*/
ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId);
/**
* 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。
* 由工时模块在 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.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
@@ -17,6 +18,7 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
@@ -26,10 +28,12 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
@@ -44,6 +48,9 @@ import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -83,12 +90,16 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ProjectRequirementMapper projectRequirementMapper;
@Resource
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Resource
private ProjectTaskStatusLogMapper projectTaskStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@@ -114,6 +125,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private DictDataApi dictDataApi;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -131,6 +144,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录
validateLeafToParentSplit(parentTask);
validatePriority(reqVO.getPriority());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
@@ -142,6 +156,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId);
task.setStatusCode(getInitialTaskStatusCode());
task.setPriority(reqVO.getPriority());
// 任务进度统一由 worklog 驱动;新建任务强制为 0
task.setProgressRate(BigDecimal.ZERO);
task.setPlannedStartDate(reqVO.getPlannedStartDate());
@@ -196,6 +211,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (!Objects.equals(oldParentId, newParentId)) {
validateLeafToParentSplit(parentTask);
}
validatePriority(reqVO.getPriority());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
// 任务进度由 worklog 驱动owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参
@@ -205,6 +221,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId);
task.setPriority(reqVO.getPriority());
task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护
@@ -229,35 +246,86 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
validateEditableProject(projectId);
validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删
Long upperOwnerId = task.getParentTaskId() == null
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE);
requireDeletePermissionOnTask(projectId, task, execution);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
}
// 仅初始态可
String fromStatus = task.getStatusCode();
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) {
// 删除路径不复用 validateEditableProject / validateExecutionAllowEdit已完成任务用专门的硬卡拦截
// 其它非 completed 状态pending / active / paused / cancelled允许删除。
if (Objects.equals(task.getStatusCode(), ProjectTaskConstants.STATUS_COMPLETED)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE);
}
String reason = reqVO.getReason().trim();
int deleteCount = projectTaskMapper.deleteByIdAndStatus(taskId, fromStatus);
if (deleteCount != 1) {
String fromStatus = task.getStatusCode();
Long parentTaskId = task.getParentTaskId();
// 收集自身 + 子孙任务 id级联软删工作日志 / 协办人 / 任务
List<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);
}
// 父任务进度回算(执行 / 项目需求 progressRate 是读时聚合,无需主动刷新)
if (parentTaskId != null) {
recalcParentProgressFrom(parentTaskId);
}
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
}
@Override
public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) {
validateProjectExists(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 预检也走"上级硬卡",防止"无权限的人调 precheck 试探下挂数量"
requireDeletePermissionOnTask(projectId, task, execution);
int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId);
int worklogCount = taskWorklogMapper.countByTaskId(taskId);
ProjectTaskDeletePrecheckRespVO respVO = new ProjectTaskDeletePrecheckRespVO();
respVO.setChildTaskCount(childTaskCount);
respVO.setWorklogCount(worklogCount);
respVO.setHasDependentData(childTaskCount > 0 || worklogCount > 0);
return respVO;
}
/**
* 任务删除"上级硬卡":一级任务由执行负责人 OR 项目负责人(权限码)操作;
* 子任务由父任务负责人 OR 项目负责人(权限码)操作。
* <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) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null
@@ -398,6 +466,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
}
fillProjectRequirementInfo(List.of(respVO), execution);
return respVO;
}
@@ -442,6 +511,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
// 执行 owner 单条查询整页共享URL 路径定 executionId
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId();
fillProjectRequirementInfo(list, execution);
list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
@@ -465,6 +535,28 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
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) {
if (activeList == null || activeList.isEmpty()) {
return List.of();
@@ -970,6 +1062,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
valueOf(after, ProjectTaskDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode),
valueOf(after, ProjectTaskDO::getStatusCode));
appendFieldChange(fieldChanges, "priority",
valueOf(before, ProjectTaskDO::getPriority),
valueOf(after, ProjectTaskDO::getPriority));
appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate),
valueOf(after, ProjectTaskDO::getProgressRate));
appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectTaskDO::getPlannedStartDate),
@@ -1035,6 +1130,20 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
}
@VisibleForTesting
void validatePriority(String priority) {
try {
Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.REQ_PRIORITY,
java.util.List.of(priority.trim())).getCheckedData();
if (Boolean.TRUE.equals(valid)) {
return;
}
} catch (RuntimeException ex) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID);
}
throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID);
}
/**
* 从给定父任务向上递归刷新进度:父进度 = AVG(直接子.progressRate)scale=2 HALF_UP。
* 终止条件parentTaskId 为 null已到根或当前层进度未发生变化截断进一步刷新

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.service.project.task.worklog;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
@@ -22,10 +23,12 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -64,6 +67,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Resource
private AdminUserApi adminUserApi;
@Resource
private DictDataApi dictDataApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
/**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
@@ -105,6 +110,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
validateDurationGranularity(reqVO.getDurationHours());
validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null);
validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null);
validateDifficulty(reqVO.getDifficulty());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
@@ -139,6 +145,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
// 与该用户其他工时记录不可重叠(排除自身)
validateNoOverlap(taskId, worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId());
validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId());
validateDifficulty(reqVO.getDifficulty());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
@@ -148,6 +155,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
update.setEndDate(reqVO.getEndDate());
update.setDurationHours(reqVO.getDurationHours());
update.setProgressRate(reqVO.getProgressRate());
update.setDifficulty(reqVO.getDifficulty());
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update);
@@ -210,6 +218,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
worklog.setEndDate(reqVO.getEndDate());
worklog.setDurationHours(reqVO.getDurationHours());
worklog.setProgressRate(reqVO.getProgressRate());
worklog.setDifficulty(reqVO.getDifficulty());
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
worklog.setAttachments(reqVO.getAttachments());
worklog.setDifficulty(reqVO.getDifficulty());
@@ -395,6 +404,24 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
return nicknameMap;
}
/**
* 校验完成难度为字典 {@code rdms_worklog_difficulty} 中的合法 value。
* 走 DictDataApi 而非本地 Enum运维加新档无需代码发版。
*/
@VisibleForTesting
void validateDifficulty(String difficulty) {
try {
Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.WORKLOG_DIFFICULTY,
java.util.List.of(difficulty.trim())).getCheckedData();
if (Boolean.TRUE.equals(valid)) {
return;
}
} catch (RuntimeException ex) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID);
}
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DIFFICULTY_INVALID);
}
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;

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