fix(project): 修复项目执行管理中的多项问题并优化字典数据功能
- 修复字典数据分页接口命名错误,从 getDictTypePage 改为 getDictDataPage - 修复字典数据查询排序逻辑,从 orderByDesc 改为 orderByAsc 并增加 id 排序 - 更新字典数据分页请求参数验证,将 dictType 设为必填项并添加非空验证 - 在字典数据简单响应对象中添加备注字段 - 修复项目执行删除权限验证,允许非初始态执行删除但阻止已完成执行删除 - 添加项目执行和任务优先级验证错误码常量 - 优化项目执行删除逻辑,支持级联软删相关任务、工作日志和协办数据 - 添加项目需求关联验证,防止无效需求关联到执行 - 修复执行协办数据批量删除方法的参数验证逻辑 - 添加工时完成难度验证错误码,完善项目需求删除前检查机制 - 更新 CLAUDE.md 文档,补充种子 SQL 编写规范和雪花 ID 处理说明
This commit is contained in:
@@ -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 ==========
|
||||
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
|
||||
@@ -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 后比对)。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "计划开始日期")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,10 @@ public class ProjectExecutionRespVO {
|
||||
private Long projectId;
|
||||
@Schema(description = "关联项目需求编号")
|
||||
private Long projectRequirementId;
|
||||
@Schema(description = "关联项目需求名称(service 层批量回填,避免 N+1)", example = "前端联调-需求A")
|
||||
private String projectRequirementName;
|
||||
@Schema(description = "关联项目需求状态编码(service 层批量回填)", example = "implementing")
|
||||
private String projectRequirementStatusCode;
|
||||
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
|
||||
private String executionName;
|
||||
@Schema(description = "执行类型", example = "feature")
|
||||
@@ -30,6 +34,10 @@ public class ProjectExecutionRespVO {
|
||||
private String statusCode;
|
||||
@Schema(description = "执行状态名称", example = "待开始")
|
||||
private String statusName;
|
||||
@Schema(description = "优先级编码(字典 rdms_req_priority),0=P0(最高) ~ 3=P3(最低)", example = "0")
|
||||
private String priority;
|
||||
@Schema(description = "优先级名称(字典 label,后端不填、前端按字典 cache 自译)", example = "P0")
|
||||
private String priorityName;
|
||||
@Schema(description = "是否终态", example = "false")
|
||||
private Boolean terminal;
|
||||
@Schema(description = "当前状态是否允许编辑", example = "true")
|
||||
|
||||
@@ -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 = "计划开始日期")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,12 @@ public class ProjectTaskRespVO {
|
||||
private Long projectId;
|
||||
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
|
||||
private Long executionId;
|
||||
@Schema(description = "所属执行关联的项目需求编号(service 层批量回填)")
|
||||
private Long projectRequirementId;
|
||||
@Schema(description = "项目需求名称(service 层批量回填,避免 N+1)", example = "前端联调-需求A")
|
||||
private String projectRequirementName;
|
||||
@Schema(description = "项目需求状态编码(service 层批量回填)", example = "implementing")
|
||||
private String projectRequirementStatusCode;
|
||||
@Schema(description = "父任务编号")
|
||||
private Long parentTaskId;
|
||||
@Schema(description = "父任务负责人用户编号;一级任务为 null,子任务用于前端判断"
|
||||
@@ -41,6 +47,10 @@ public class ProjectTaskRespVO {
|
||||
private String statusCode;
|
||||
@Schema(description = "任务状态名称", example = "待开始")
|
||||
private String statusName;
|
||||
@Schema(description = "优先级编码(字典 rdms_req_priority),0=P0(最高) ~ 3=P3(最低)", example = "0")
|
||||
private String priority;
|
||||
@Schema(description = "优先级名称(字典 label,后端不填、前端按字典 cache 自译)", example = "P0")
|
||||
private String priorityName;
|
||||
@Schema(description = "是否终态", example = "false")
|
||||
private Boolean terminal;
|
||||
@Schema(description = "当前状态是否允许编辑", example = "true")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.njcn.rdms.module.project.dal.dataobject.project.execution;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
@@ -27,8 +29,9 @@ public class ProjectExecutionDO extends BaseDO {
|
||||
*/
|
||||
private Long projectId;
|
||||
/**
|
||||
* 关联项目需求编号,第一阶段仅保留字段
|
||||
* 关联项目需求编号。PUT 全字段语义:null=解绑(参见 CLAUDE.md 接口语义章节)。
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Long projectRequirementId;
|
||||
/**
|
||||
* 执行名称
|
||||
@@ -46,6 +49,10 @@ public class ProjectExecutionDO extends BaseDO {
|
||||
* 执行状态编码
|
||||
*/
|
||||
private String statusCode;
|
||||
/**
|
||||
* 优先级字典 rdms_req_priority,0=P0(最高) ~ 3=P3(最低)
|
||||
*/
|
||||
private String priority;
|
||||
/**
|
||||
* 计划开始日期
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,10 @@ public class ProjectTaskDO extends BaseDO {
|
||||
* 任务状态编码
|
||||
*/
|
||||
private String statusCode;
|
||||
/**
|
||||
* 优先级字典 rdms_req_priority,0=P0(最高) ~ 3=P3(最低)
|
||||
*/
|
||||
private String priority;
|
||||
/**
|
||||
* 任务进度
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -113,4 +113,12 @@ public interface ProjectRequirementService {
|
||||
*/
|
||||
List<ProjectRequirementStatusDictRespVO> getRequirementTerminalStatusDict();
|
||||
|
||||
/**
|
||||
* 校验项目需求可被执行关联。
|
||||
* <p>用于执行模块在 create / update 时确认传入的 projectRequirementId 合法。
|
||||
* 校验:存在且未软删 + 属于本项目 + 非终态。任一不满足抛 ServiceException。
|
||||
* 传入 requirementId 为 null 时直接放行(执行侧已确定 projectRequirementId 可选)。
|
||||
*/
|
||||
void validateUsableForExecution(Long projectId, Long projectRequirementId);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<requirementId, progressRate>。
|
||||
* 公式与 fillRequirementProgress 对齐:自下而上 DFS,pool = 自己挂的执行平均 ∪ 直接子需求进度。
|
||||
*/
|
||||
@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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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。未命中执行 → 缺省 false,complete 按钮不下发。
|
||||
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。
|
||||
|
||||
@@ -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 后调用。
|
||||
|
||||
@@ -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(已到根);或当前层进度未发生变化(截断进一步刷新)。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
// maybeFillActualDates:fromStatus="pending" 的 initialFlag=true → 触发 actualStartDate 填充并调 updateById
|
||||
ObjectStatusModelDO pendingInitialStatus = createExecutionStatus("pending", true);
|
||||
pendingInitialStatus.setInitialFlag(Boolean.TRUE);
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(pendingInitialStatus);
|
||||
|
||||
projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO);
|
||||
|
||||
@@ -312,6 +376,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() {
|
||||
Long projectId = 2001L;
|
||||
Long executionId = 5001L;
|
||||
// ownerId=3001L;cancel 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
|
||||
reqVO.setActionCode("cancel");
|
||||
@@ -322,10 +387,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel"))
|
||||
.thenReturn(createTransition("cancel", "cancelled", true));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
|
||||
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
|
||||
try (MockedStatic<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=3001L;complete 是 owner-only 动作,登录用户必须是负责人才能通过 validateOwnerForAction
|
||||
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||
execution.setStatusCode("active");
|
||||
ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
|
||||
@@ -362,10 +429,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete"))
|
||||
.thenReturn(null);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
|
||||
|
||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
|
||||
try (MockedStatic<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user