fix(personal-item): 个人事项&任务添加type类型字段

This commit is contained in:
caozehui
2026-05-21 13:59:45 +08:00
parent d069948d2a
commit 19637d74a4
9 changed files with 46 additions and 691 deletions

View File

@@ -19,6 +19,9 @@ public class PersonalItemRespVO {
@Schema(description = "个人事项标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "整理供应商沟通纪要")
private String taskTitle;
@Schema(description = "个人事项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "todo")
private String type;
@Schema(description = "负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001")
private Long ownerId;

View File

@@ -25,6 +25,11 @@ public class PersonalItemSaveReqVO {
@Size(max = 300, message = "个人事项标题长度不能超过300个字符")
private String taskTitle;
@Schema(description = "个人事项类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "todo")
@NotBlank(message = "个人事项类型不能为空")
@Size(max = 32, message = "个人事项类型长度不能超过32个字符")
private String type;
@Schema(description = "个人事项进度0~100", example = "60.00")
@DecimalMin(value = "0.00", message = "个人事项进度不能小于 0")
@DecimalMax(value = "100.00", message = "个人事项进度不能大于 100")

View File

@@ -31,6 +31,8 @@ public class ProjectTaskRespVO {
private Long executionOwnerId;
@Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务")
private String taskTitle;
@Schema(description = "任务类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "feature")
private String type;
@Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
private Long ownerId;
@Schema(description = "任务负责人昵称", example = "李四")

View File

@@ -25,6 +25,11 @@ public class ProjectTaskSaveReqVO {
@Size(max = 300, message = "任务标题长度不能超过300个字符")
private String taskTitle;
@Schema(description = "任务类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "feature")
@NotBlank(message = "任务类型不能为空")
@Size(max = 32, message = "任务类型长度不能超过32个字符")
private String type;
@Schema(description = "任务负责人用户编号;子任务不传时继承父任务负责人", example = "3002")
private Long ownerId;

View File

@@ -27,6 +27,8 @@ public class PersonalItemDO extends BaseDO {
private String taskTitle;
private String type;
private Long ownerId;
private String statusCode;

View File

@@ -42,6 +42,10 @@ public class ProjectTaskDO extends BaseDO {
* 任务标题
*/
private String taskTitle;
/**
* 任务类型
*/
private String type;
/**
* 任务负责人用户编号
*/

View File

@@ -94,6 +94,7 @@ public class PersonalItemServiceImpl implements PersonalItemService {
PersonalItemDO item = new PersonalItemDO();
item.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
item.setType(normalizeRequiredType(reqVO.getType(), "个人事项类型不能为空"));
item.setOwnerId(loginUserId);
item.setStatusCode(getInitialStatusCode());
item.setProgressRate(normalizeProgress(reqVO.getProgressRate()));
@@ -123,6 +124,7 @@ public class PersonalItemServiceImpl implements PersonalItemService {
PersonalItemDO before = cloneItem(item);
item.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
item.setType(normalizeRequiredType(reqVO.getType(), "个人事项类型不能为空"));
item.setProgressRate(normalizeProgress(reqVO.getProgressRate()));
item.setPlannedStartDate(reqVO.getPlannedStartDate());
item.setPlannedEndDate(reqVO.getPlannedEndDate());
@@ -361,6 +363,7 @@ public class PersonalItemServiceImpl implements PersonalItemService {
task.setExecutionId(executionId);
task.setParentTaskId(null);
task.setTaskTitle(item.getTaskTitle());
task.setType(item.getType());
task.setOwnerId(item.getOwnerId());
task.setStatusCode(item.getStatusCode());
task.setProgressRate(item.getProgressRate());
@@ -546,6 +549,7 @@ public class PersonalItemServiceImpl implements PersonalItemService {
PersonalItemDO target = new PersonalItemDO();
target.setId(source.getId());
target.setTaskTitle(source.getTaskTitle());
target.setType(source.getType());
target.setOwnerId(source.getOwnerId());
target.setStatusCode(source.getStatusCode());
target.setProgressRate(source.getProgressRate());
@@ -563,6 +567,8 @@ public class PersonalItemServiceImpl implements PersonalItemService {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "taskTitle", valueOf(before, PersonalItemDO::getTaskTitle),
valueOf(after, PersonalItemDO::getTaskTitle));
appendFieldChange(fieldChanges, "type", valueOf(before, PersonalItemDO::getType),
valueOf(after, PersonalItemDO::getType));
appendFieldChange(fieldChanges, "ownerId", valueOf(before, PersonalItemDO::getOwnerId),
valueOf(after, PersonalItemDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, PersonalItemDO::getStatusCode),
@@ -732,6 +738,13 @@ public class PersonalItemServiceImpl implements PersonalItemService {
return value.trim();
}
private String normalizeRequiredType(String value, String message) {
if (!StringUtils.hasText(value)) {
throw invalidParamException(message);
}
return value.trim();
}
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;

View File

@@ -139,6 +139,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setExecutionId(executionId);
task.setParentTaskId(parentTask == null ? null : parentTask.getId());
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId);
task.setStatusCode(getInitialTaskStatusCode());
// 任务进度统一由 worklog 驱动;新建任务强制为 0
@@ -202,6 +203,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectTaskDO before = cloneTask(task);
task.setParentTaskId(newParentId);
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
task.setOwnerId(ownerId);
task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate());
@@ -939,6 +941,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
target.setExecutionId(source.getExecutionId());
target.setParentTaskId(source.getParentTaskId());
target.setTaskTitle(source.getTaskTitle());
target.setType(source.getType());
target.setOwnerId(source.getOwnerId());
target.setStatusCode(source.getStatusCode());
target.setProgressRate(source.getProgressRate());
@@ -961,6 +964,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
valueOf(after, ProjectTaskDO::getParentTaskId));
appendFieldChange(fieldChanges, "taskTitle", valueOf(before, ProjectTaskDO::getTaskTitle),
valueOf(after, ProjectTaskDO::getTaskTitle));
appendFieldChange(fieldChanges, "type", valueOf(before, ProjectTaskDO::getType),
valueOf(after, ProjectTaskDO::getType));
appendFieldChange(fieldChanges, "ownerId", valueOf(before, ProjectTaskDO::getOwnerId),
valueOf(after, ProjectTaskDO::getOwnerId));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode),
@@ -1091,6 +1096,13 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
return value.trim();
}
private String normalizeRequiredType(String value, String message) {
if (!StringUtils.hasText(value)) {
throw invalidParamException(message);
}
return value.trim();
}
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;

View File

@@ -1,691 +0,0 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
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.attachment.AttachmentItem;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
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.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
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.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.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.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProjectTaskServiceImpl projectTaskService;
@Mock
private ProjectMapper projectMapper;
@Mock
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private TaskWorklogMapper taskWorklogMapper;
@Mock
private ProjectTaskStatusLogMapper projectTaskStatusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private ObjectStatusModelMapper objectStatusModelMapper;
@Mock
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Mock
private ProjectService projectService;
@Mock
private TaskAssigneeService taskAssigneeService;
@Mock
private AttachmentFileIdResolver attachmentFileIdResolver;
@Mock
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
*/
@BeforeEach
void setupVisibilityScopeAll() {
lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
.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());
}
@Test
void createTask_shouldInsertPendingTaskAndAutoStartProject() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0);
task.setId(9001L);
return 1;
});
Long taskId = projectTaskService.createTask(projectId, executionId, reqVO);
assertNotNull(taskId);
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
verify(projectTaskMapper).insert(taskCaptor.capture());
assertEquals(projectId, taskCaptor.getValue().getProjectId());
assertEquals(executionId, taskCaptor.getValue().getExecutionId());
assertEquals("接口联调任务", taskCaptor.getValue().getTaskTitle());
assertEquals(3002L, taskCaptor.getValue().getOwnerId());
assertEquals("pending", taskCaptor.getValue().getStatusCode());
assertEquals(BigDecimal.ZERO, taskCaptor.getValue().getProgressRate());
verify(projectService).autoStartProjectIfPending(projectId, "create_task");
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
}
@Test
void createTask_shouldPersistAttachmentFileId() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setOwnerId(3002L);
AttachmentItem attachment = new AttachmentItem();
attachment.setId("10001");
attachment.setUrl("http://oss.example.com/task/2026/05/report.txt");
attachment.setName("report.txt");
attachment.setSize(3164L);
attachment.setContentType("text/plain");
reqVO.setAttachments(List.of(attachment));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0);
task.setId(9001L);
return 1;
});
projectTaskService.createTask(projectId, executionId, reqVO);
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
verify(projectTaskMapper).insert(taskCaptor.capture());
verify(attachmentFileIdResolver).resolve(reqVO.getAttachments());
assertEquals("10001", taskCaptor.getValue().getAttachments().get(0).getId());
}
@Test
void createSubTask_whenOwnerBlank_shouldInheritParentOwner() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskDO parentTask = createTask(projectId, executionId, 8001L, 3002L);
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(parentTask.getId());
reqVO.setOwnerId(null);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTask.getId()))
.thenReturn(parentTask);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0);
task.setId(9002L);
return 1;
});
projectTaskService.createTask(projectId, executionId, reqVO);
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
verify(projectTaskMapper).insert(taskCaptor.capture());
assertEquals(3002L, taskCaptor.getValue().getOwnerId());
assertEquals(parentTask.getId(), taskCaptor.getValue().getParentTaskId());
}
@Test
void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setOwnerId(3999L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID.getCode(), ex.getCode());
}
@Test
void createTask_whenParentNotInSameExecution_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(8001L);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, 8001L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID.getCode(), ex.getCode());
}
@Test
void changeTaskStatus_shouldUseTransitionAndWriteLogs() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("start");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start"))
.thenReturn(createTransition("start", "active", false));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(1);
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
verify(projectObjectAuthorizationService).checkOwnerOrProjectPermission(projectId, 3002L,
"project:task:status");
verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null);
ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class);
verify(projectTaskStatusLogMapper).insert(statusCaptor.capture());
assertEquals("start", statusCaptor.getValue().getActionType());
assertEquals("pending", statusCaptor.getValue().getFromStatus());
assertEquals("active", statusCaptor.getValue().getToStatus());
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
}
@Test
void changeTaskStatus_whenReasonRequiredButBlank_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason(" ");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
}
@Test
void changeTaskStatus_whenConcurrentModified_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("start");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start"))
.thenReturn(createTransition("start", "active", false));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(0);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode());
}
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
@Test
void changeTaskStatus_whenCancelChild_shouldRecalculateParentProgressWithExcludedStatuses() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskDO remainingChild = createTask(projectId, executionId, 9002L, 3002L);
remainingChild.setProgressRate(new BigDecimal("100.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of(remainingChild));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("100.00").compareTo(v) == 0));
}
@Test
void changeTaskStatus_whenAllChildrenExcluded_shouldResetParentProgressToZero() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
parent.setProgressRate(new BigDecimal("80.00"));
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
reqVO.setActionCode("cancel");
reqVO.setReason("任务取消");
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
.thenReturn(createTerminalStatus("task", "cancelled"));
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled"));
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
.thenReturn(List.of());
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
}
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("0.00").compareTo(v) == 0));
}
@Test
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
parentTask.setProgressRate(new BigDecimal("30.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS.getCode(), ex.getCode());
verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class));
}
@Test
void createTask_whenParentIsLeafWithWorklog_shouldThrowLeafToParentForbiddenWorklog() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
parentTask.setProgressRate(BigDecimal.ZERO);
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG.getCode(), ex.getCode());
verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class));
}
@Test
void createTask_whenParentAlreadyHasChildren_shouldSkipLeafCheckAndRecalc() {
Long projectId = 2001L;
Long executionId = 5001L;
Long parentTaskId = 8001L;
// 父任务进度 60%,但已有 1 个子任务,已是父任务,不再触发"叶子转父"校验
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
parentTask.setProgressRate(new BigDecimal("60.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
// 已有 1 个子任务 → 跳过叶子转父校验
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1);
// 插入新任务id=9002父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
ProjectTaskDO task = inv.getArgument(0);
task.setId(9002L);
return 1;
});
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
ProjectTaskDO existingChild = createTask(projectId, executionId, 9001L, 3002L);
existingChild.setProgressRate(new BigDecimal("60.00"));
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(BigDecimal.ZERO);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(existingChild, newChild));
projectTaskService.createTask(projectId, executionId, reqVO);
ArgumentCaptor<BigDecimal> progressCaptor = ArgumentCaptor.forClass(BigDecimal.class);
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId), progressCaptor.capture());
assertEquals(0, new BigDecimal("30.00").compareTo(progressCaptor.getValue()));
verify(taskWorklogMapper, never()).existsByTaskId(parentTaskId);
}
@Test
void createTask_withLeafParentZeroProgress_shouldRecalcGrandparentChain() {
Long projectId = 2001L;
Long executionId = 5001L;
Long grandparentId = 7000L;
Long parentTaskId = 8001L;
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
parentTask.setProgressRate(BigDecimal.ZERO);
parentTask.setParentTaskId(grandparentId);
// 爷爷的初值刻意设为 50.00,便于稳定验证"递归爬到爷爷且发生写入"
ProjectTaskDO grandparent = createTask(projectId, executionId, grandparentId, 3002L);
grandparent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false);
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
ProjectTaskDO t = inv.getArgument(0);
t.setId(9002L);
return 1;
});
// 父链parent(8001) -> grandparent(7000) -> null
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
newChild.setProgressRate(new BigDecimal("80.00"));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
.thenReturn(List.of(newChild));
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId, Collections.emptyList()))
.thenReturn(List.of(parentTask));
projectTaskService.createTask(projectId, executionId, reqVO);
// parent: AVG([80.00]) = 80.00
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("80.00").compareTo(v) == 0));
// 爷爷mock 返回的 parentTask 仍是初始引用progress=0未被自动改写
// → 爷爷应被设为 0而其原值 50.00 不等,会触发更新
verify(projectTaskMapper).updateProgressRateById(eq(grandparentId), any(BigDecimal.class));
}
@Test
void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
Long oldParentId = 8001L;
Long newParentId = 8002L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(oldParentId);
task.setProgressRate(new BigDecimal("70.00"));
ProjectTaskDO oldParent = createTask(projectId, executionId, oldParentId, 3002L);
ProjectTaskDO newParent = createTask(projectId, executionId, newParentId, 3002L);
newParent.setProgressRate(BigDecimal.ZERO);
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setId(taskId);
reqVO.setParentTaskId(newParentId);
reqVO.setOwnerId(3002L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, newParentId))
.thenReturn(newParent);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
// 校验"叶子转父":新父 8002 当前是叶子,进度=0无工时 → 通过
when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false);
// 当前任务自身:是叶子
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(0);
// 递归刷新两条链:旧父 8001 与 新父 8002
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId, Collections.emptyList()))
.thenReturn(List.of()); // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId, Collections.emptyList()))
.thenReturn(List.of(task));
projectTaskService.updateTask(projectId, executionId, reqVO);
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
// 旧父无有效子任务且当前已为 0不重复更新
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
}
private ProjectTaskSaveReqVO createTaskReqVO() {
ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO();
reqVO.setTaskTitle("接口联调任务");
reqVO.setTaskDesc("完成接口联调");
return reqVO;
}
private ProjectDO createEditableProject(Long projectId) {
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setProjectName("测试项目");
project.setStatusCode("pending");
return project;
}
private ProjectExecutionDO createEditableExecution(Long projectId, Long executionId) {
ProjectExecutionDO execution = new ProjectExecutionDO();
execution.setId(executionId);
execution.setProjectId(projectId);
execution.setExecutionName("接口联调");
execution.setStatusCode("pending");
return execution;
}
private ProjectTaskDO createTask(Long projectId, Long executionId, Long taskId, Long ownerId) {
ProjectTaskDO task = new ProjectTaskDO();
task.setId(taskId);
task.setProjectId(projectId);
task.setExecutionId(executionId);
task.setTaskTitle("接口联调任务");
task.setOwnerId(ownerId);
task.setStatusCode("pending");
task.setProgressRate(BigDecimal.ZERO);
return task;
}
private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId);
member.setUserId(userId);
return member;
}
private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) {
ObjectStatusModelDO status = new ObjectStatusModelDO();
status.setObjectType(objectType);
status.setStatusCode(statusCode);
status.setStatusName(statusCode);
status.setAllowEdit(allowEdit);
return status;
}
private ObjectStatusModelDO createTerminalStatus(String objectType, String statusCode) {
ObjectStatusModelDO status = createStatus(objectType, statusCode, false);
status.setTerminalFlag(true);
return status;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
transition.setActionName(actionCode);
transition.setToStatusCode(toStatus);
transition.setNeedReason(needReason);
return transition;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
}