diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java new file mode 100644 index 0000000..e6310fb --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java @@ -0,0 +1,1297 @@ +package com.njcn.rdms.module.project.service.project; + +import com.google.common.annotations.VisibleForTesting; +import com.njcn.rdms.framework.common.pojo.PageResult; +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.ProjectObjectConstants; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementCloseReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementLifecycleRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementModuleReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementModuleRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementSaveReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementSplitReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementStatusActionReqVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementStatusDictRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementStatusTransitionRespVO; +import com.njcn.rdms.module.project.controller.admin.project.vo.requirement.ProjectRequirementUpdateReqVO; +import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; +import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO; +import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementModuleDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementStatusLogDO; +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.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.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.security.annotation.CheckObjectPermission; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * 项目需求 Service 实现类 + */ +@Service +public class ProjectRequirementServiceImpl implements ProjectRequirementService { + + private static final String REQUIREMENT_OBJECT_TYPE = "project_requirement"; + private static final String PROJECT_OBJECT_TYPE = ProjectObjectConstants.OBJECT_TYPE; + + private static final String STATUS_PENDING_CONFIRM = "pending_confirm"; + private static final String STATUS_PENDING_REVIEW = "pending_review"; + private static final String STATUS_IMPLEMENTING = "implementing"; + private static final String STATUS_ACCEPTED = "accepted"; + private static final String STATUS_CLOSED = "closed"; + private static final String STATUS_REJECTED = "rejected"; + private static final String STATUS_CANCELLED = "cancelled"; + private static final String SOURCE_TYPE_PRODUCT_REQUIREMENT = "product_requirement"; + + private static final List TERMINAL_STATUSES = List.of( + STATUS_CLOSED, STATUS_REJECTED, STATUS_CANCELLED); + private static final List CHILD_ALLOW_CLOSE_STATUSES = List.of( + STATUS_CLOSED, STATUS_CANCELLED, STATUS_REJECTED, STATUS_ACCEPTED); + private static final List ALLOW_DELETE_STATUSES = List.of( + STATUS_PENDING_CONFIRM, STATUS_PENDING_REVIEW); + private static final List CHILD_ALLOW_CANCEL_STATUSES = List.of( + STATUS_REJECTED, STATUS_CANCELLED); + + private static final String PROJECT_CREATE_PERMISSION = "project:project:create"; + private static final String PROJECT_QUERY_PERMISSION = "project:project:query"; + private static final String PROJECT_UPDATE_PERMISSION = ProjectObjectConstants.PERMISSION_UPDATE; + private static final String PROJECT_STATUS_PERMISSION = ProjectObjectConstants.PERMISSION_STATUS; + private static final String PROJECT_DELETE_PERMISSION = ProjectObjectConstants.PERMISSION_DELETE; + private static final String PROJECT_SPLIT_PERMISSION = ProjectObjectConstants.PERMISSION_SPLIT; + + private static final String ACTION_CREATE = "create"; + private static final String ACTION_UPDATE = "update"; + private static final String ACTION_DELETE = "delete"; + private static final String ACTION_SPLIT = "split"; + private static final String ACTION_CLOSE = "close"; + private static final String ACTION_ACCEPT = "accept"; + private static final String ACTION_CANCEL = "cancel"; + + private static final String ACTION_AUTO_DERIVE = "auto_derive"; + private static final String ACTION_SYNC_PRODUCT_STATUS = "sync_project_requirement_status"; + private static final String BIZ_TYPE_REQUIREMENT = REQUIREMENT_OBJECT_TYPE; + private static final String PRODUCT_BIZ_TYPE_REQUIREMENT = SOURCE_TYPE_PRODUCT_REQUIREMENT; + private static final String AUTO_DERIVE_REASON = "根据子需求状态自动推导"; + private static final String SYNC_PRODUCT_REASON = "根据项目需求根节点状态自动回写"; + + @Resource + private ProjectRequirementMapper requirementMapper; + @Resource + private ProjectRequirementModuleMapper moduleMapper; + @Resource + private ProjectRequirementStatusLogMapper statusLogMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private ProductRequirementMapper productRequirementMapper; + @Resource + private ProductRequirementStatusLogMapper productRequirementStatusLogMapper; + @Resource + private ObjectStatusTransitionMapper statusTransitionMapper; + @Resource + private ObjectStatusModelMapper statusModelMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#createReqVO.projectId", + permission = PROJECT_CREATE_PERMISSION) + public Long createRequirement(ProjectRequirementSaveReqVO createReqVO) { + Long moduleId = resolveModuleId(createReqVO.getModuleId(), createReqVO.getProjectId()); + validateModuleBelongsToProject(moduleId, createReqVO.getProjectId()); + + ProjectRequirementDO requirement = new ProjectRequirementDO(); + requirement.setProjectId(createReqVO.getProjectId()); + requirement.setParentId(0L); + requirement.setModuleId(moduleId); + requirement.setReviewRequired(createReqVO.getReviewRequired()); + requirement.setTitle(createReqVO.getTitle().trim()); + requirement.setDescription(normalizeNullableText(createReqVO.getDescription())); + requirement.setCategory(createReqVO.getCategory()); + requirement.setSourceType("manual"); + requirement.setPriority(createReqVO.getPriority()); + String initialStatus = Objects.equals(createReqVO.getReviewRequired(), 1) + ? STATUS_PENDING_REVIEW : STATUS_IMPLEMENTING; + requirement.setStatusCode(initialStatus); + requirement.setProposerId(createReqVO.getProposerId()); + requirement.setProposerNickname(normalizeNullableText(createReqVO.getProposerNickname())); + requirement.setWorkHours(createReqVO.getWorkHours()); + requirement.setCurrentHandlerUserId(createReqVO.getCurrentHandlerUserId()); + requirement.setCurrentHandlerUserNickname(normalizeNullableText(createReqVO.getCurrentHandlerUserNickname())); + requirement.setSort(createReqVO.getSort() != null ? createReqVO.getSort() : 0); + requirementMapper.insert(requirement); + + writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus, + buildRequirementFieldChanges(null, requirement), null); + return requirement.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#updateReqVO.projectId", + permission = PROJECT_UPDATE_PERMISSION) + public void updateRequirement(ProjectRequirementUpdateReqVO updateReqVO) { + ProjectRequirementDO requirement = validateRequirementExists(updateReqVO.getId()); + validateRequirementBelongsToProject(requirement, updateReqVO.getProjectId()); + validateRequirementEditable(requirement); + + Long moduleId = resolveModuleId(updateReqVO.getModuleId(), updateReqVO.getProjectId()); + validateModuleBelongsToProject(moduleId, updateReqVO.getProjectId()); + + ProjectRequirementDO before = cloneRequirement(requirement); + String fromStatus = requirement.getStatusCode(); + requirement.setModuleId(moduleId); + requirement.setReviewRequired(updateReqVO.getReviewRequired()); + requirement.setTitle(updateReqVO.getTitle().trim()); + requirement.setDescription(normalizeNullableText(updateReqVO.getDescription())); + requirement.setCategory(updateReqVO.getCategory()); + requirement.setPriority(updateReqVO.getPriority()); + requirement.setProposerId(updateReqVO.getProposerId()); + requirement.setProposerNickname(normalizeNullableText(updateReqVO.getProposerNickname())); + requirement.setWorkHours(updateReqVO.getWorkHours()); + requirement.setCurrentHandlerUserId(updateReqVO.getCurrentHandlerUserId()); + requirement.setCurrentHandlerUserNickname(normalizeNullableText(updateReqVO.getCurrentHandlerUserNickname())); + requirement.setSort(updateReqVO.getSort() != null ? updateReqVO.getSort() : 0); + requirementMapper.updateById(requirement); + + writeBizAuditLog(requirement, ACTION_UPDATE, fromStatus, requirement.getStatusCode(), + buildRequirementFieldChanges(before, requirement), null); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_QUERY_PERMISSION) + public ProjectRequirementRespVO getRequirement(Long id, Long projectId) { + ProjectRequirementDO requirement = validateRequirementExists(id); + validateRequirementBelongsToProject(requirement, projectId); + return buildRequirementRespVO(requirement); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", + permission = PROJECT_QUERY_PERMISSION) + public PageResult getRequirementPage(ProjectRequirementPageReqVO pageReqVO) { + if (pageReqVO.getModuleId() != null) { + if (isAllRequirementsModule(pageReqVO.getModuleId())) { + pageReqVO.setModuleId(null); + } else { + List moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProjectId()); + pageReqVO.setModuleIds(moduleIds); + pageReqVO.setModuleId(null); + } + } + PageResult pageResult = requirementMapper.selectPage(pageReqVO); + Map statusModelMap = getStatusModelMap(); + List list = pageResult.getList().stream() + .map(requirement -> buildRequirementRespVO(requirement, statusModelMap)) + .collect(Collectors.toList()); + return new PageResult<>(list, pageResult.getTotal()); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#pageReqVO.projectId", + permission = PROJECT_QUERY_PERMISSION) + public PageResult getRequirementTree(ProjectRequirementPageReqVO pageReqVO) { + Long moduleId = pageReqVO.getModuleId(); + Long projectId = pageReqVO.getProjectId(); + if (moduleId != null) { + pageReqVO.setModuleIds(getAllModuleIdsWithChildren(moduleId, projectId)); + pageReqVO.setModuleId(null); + } + pageReqVO.setParentId(null); + + List matchedRequirements = requirementMapper.selectList(pageReqVO); + if (matchedRequirements.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + + Map statusModelMap = getStatusModelMap(); + + Set rootIds = new HashSet<>(); + Set pathNodeIds = new HashSet<>(); + Map requirementCache = buildRequirementCacheWithAncestors(matchedRequirements); + for (ProjectRequirementDO requirement : matchedRequirements) { + pathNodeIds.add(requirement.getId()); + Long rootId = findRootRequirementIdAndCollectPath(requirement, requirementCache, pathNodeIds); + rootIds.add(rootId); + } + + List rootRequirements = requirementMapper.selectBatchIds(rootIds); + rootRequirements.sort((a, b) -> { + int sortCompare = Integer.compare(a.getSort() != null ? a.getSort() : 0, + b.getSort() != null ? b.getSort() : 0); + if (sortCompare != 0) { + return sortCompare; + } + return b.getCreateTime().compareTo(a.getCreateTime()); + }); + + int pageNo = pageReqVO.getPageNo() != null ? pageReqVO.getPageNo() : 1; + int pageSize = pageReqVO.getPageSize() != null ? pageReqVO.getPageSize() : 10; + int total = rootRequirements.size(); + int fromIndex = (pageNo - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, total); + if (fromIndex >= total) { + return new PageResult<>(Collections.emptyList(), (long) total); + } + + List pagedRootRequirements = rootRequirements.subList(fromIndex, toIndex); + Map> childrenMap = buildPathChildrenMap(pathNodeIds); + List list = pagedRootRequirements.stream() + .map(requirement -> buildRequirementRespVOWithPathChildren(requirement, pathNodeIds, childrenMap, statusModelMap)) + .collect(Collectors.toList()); + return new PageResult<>(list, (long) total); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", + permission = PROJECT_STATUS_PERMISSION) + public void changeRequirementStatus(ProjectRequirementStatusActionReqVO reqVO) { + ProjectRequirementDO requirement = validateRequirementExists(reqVO.getId()); + validateRequirementBelongsToProject(requirement, reqVO.getProjectId()); + String actionCode = reqVO.getActionCode().trim(); + String fromStatus = requirement.getStatusCode(); + String reason = normalizeNullableText(reqVO.getReason()); + ProjectRequirementDO before = cloneRequirement(requirement); + + ObjectStatusTransitionDO transition = validateRequirementTransition(fromStatus, actionCode); + validateTransitionReason(transition, reason); + String toStatus = transition.getToStatusCode(); + + if (ACTION_ACCEPT.equals(actionCode) || ACTION_CLOSE.equals(actionCode)) { + validateAllChildrenAllowCloseOrAccept(reqVO.getId()); + } + if (ACTION_CANCEL.equals(actionCode) && hasChildren(requirement.getId())) { + validateParentCancelAllowed(reqVO.getId()); + } + if (ACTION_CLOSE.equals(actionCode)) { + closeAllAcceptedChildren(reqVO.getId(), reason); + } + + int updateCount = requirementMapper.updateStatusByIdAndStatus(requirement.getId(), fromStatus, toStatus, reason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED); + } + requirement.setStatusCode(toStatus); + requirement.setLastStatusReason(reason); + + writeRequirementStatusLog(requirement, actionCode, fromStatus, toStatus, reason); + writeBizAuditLog(requirement, actionCode, fromStatus, toStatus, + buildRequirementFieldChanges(before, requirement), reason); + refreshAncestorStatusAndSyncProduct(requirement.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_DELETE_PERMISSION) + public void deleteRequirement(Long id, Long projectId) { + ProjectRequirementDO requirement = validateRequirementExists(id); + validateRequirementBelongsToProject(requirement, projectId); + String fromStatus = requirement.getStatusCode(); + + List children = requirementMapper.selectListByParentId(id); + if (!children.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_HAS_CHILDREN); + } + if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_DELETE); + } + + int deleteCount = requirementMapper.deleteByIdAndStatus(id, fromStatus); + if (deleteCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED); + } + writeBizAuditLog(requirement, ACTION_DELETE, fromStatus, null, + buildRequirementFieldChanges(requirement, null), null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", + permission = PROJECT_SPLIT_PERMISSION) + public Long splitRequirement(ProjectRequirementSplitReqVO reqVO) { + ProjectRequirementDO parentRequirement = validateRequirementExists(reqVO.getParentId()); + validateRequirementBelongsToProject(parentRequirement, reqVO.getProjectId()); + validateParentAllowSplit(parentRequirement); + + Long moduleId = reqVO.getModuleId() != null ? reqVO.getModuleId() : parentRequirement.getModuleId(); + validateModuleBelongsToProject(moduleId, reqVO.getProjectId()); + + ProjectRequirementDO childRequirement = new ProjectRequirementDO(); + childRequirement.setParentId(reqVO.getParentId()); + childRequirement.setProjectId(parentRequirement.getProjectId()); + childRequirement.setModuleId(moduleId); + childRequirement.setReviewRequired(reqVO.getReviewRequired()); + childRequirement.setTitle(reqVO.getTitle().trim()); + childRequirement.setDescription(normalizeNullableText(reqVO.getDescription())); + childRequirement.setCategory(reqVO.getCategory()); + childRequirement.setSourceType(parentRequirement.getSourceType()); + childRequirement.setProductRequirementId(parentRequirement.getProductRequirementId()); + childRequirement.setSourceBizId(parentRequirement.getSourceBizId()); + childRequirement.setPriority(reqVO.getPriority()); + String initialStatus = Objects.equals(reqVO.getReviewRequired(), 1) + ? STATUS_PENDING_REVIEW : STATUS_IMPLEMENTING; + childRequirement.setStatusCode(initialStatus); + childRequirement.setProposerId(reqVO.getProposerId()); + childRequirement.setProposerNickname(normalizeNullableText(reqVO.getProposerNickname())); + childRequirement.setWorkHours(reqVO.getWorkHours()); + childRequirement.setCurrentHandlerUserId(reqVO.getCurrentHandlerUserId()); + childRequirement.setCurrentHandlerUserNickname(normalizeNullableText(reqVO.getCurrentHandlerUserNickname())); + childRequirement.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0); + requirementMapper.insert(childRequirement); + + writeBizAuditLog(childRequirement, ACTION_CREATE, null, initialStatus, + buildRequirementFieldChanges(null, childRequirement), null); + writeBizAuditLog(parentRequirement, ACTION_SPLIT, parentRequirement.getStatusCode(), + parentRequirement.getStatusCode(), null, null); + return childRequirement.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", + permission = PROJECT_STATUS_PERMISSION) + public void closeRequirement(ProjectRequirementCloseReqVO reqVO) { + ProjectRequirementDO requirement = validateRequirementExists(reqVO.getId()); + validateRequirementBelongsToProject(requirement, reqVO.getProjectId()); + String fromStatus = requirement.getStatusCode(); + String reason = reqVO.getReason().trim(); + ProjectRequirementDO before = cloneRequirement(requirement); + + if (!STATUS_ACCEPTED.equals(fromStatus)) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_CLOSE); + } + validateAllChildrenAllowCloseOrAccept(reqVO.getId()); + closeAllAcceptedChildren(reqVO.getId(), reason); + + int updateCount = requirementMapper.updateStatusByIdAndStatus( + requirement.getId(), fromStatus, STATUS_CLOSED, reason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED); + } + requirement.setStatusCode(STATUS_CLOSED); + requirement.setLastStatusReason(reason); + + writeRequirementStatusLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, reason); + writeBizAuditLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, + buildRequirementFieldChanges(before, requirement), reason); + refreshAncestorStatusAndSyncProduct(requirement.getId()); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_QUERY_PERMISSION) + public List getAllowedTransitions(Long requirementId, Long projectId) { + ProjectRequirementDO requirement = validateRequirementExists(requirementId); + validateRequirementBelongsToProject(requirement, projectId); + String currentStatus = requirement.getStatusCode(); + + List transitions = statusTransitionMapper + .selectListByObjectTypeAndFromStatus(REQUIREMENT_OBJECT_TYPE, currentStatus); + + return transitions.stream() + // 取消动作不满足前置条件时,不再返回给前端展示按钮 + .filter(transition -> shouldExposeTransition(requirement, transition)) + .map(transition -> { + ProjectRequirementStatusTransitionRespVO vo = new ProjectRequirementStatusTransitionRespVO(); + vo.setActionCode(transition.getActionCode()); + vo.setActionName(transition.getActionName()); + vo.setToStatusCode(transition.getToStatusCode()); + ObjectStatusModelDO statusModel = statusModelMapper + .selectByObjectTypeAndStatusCode(REQUIREMENT_OBJECT_TYPE, transition.getToStatusCode()); + vo.setToStatusName(statusModel != null ? statusModel.getStatusName() : transition.getToStatusCode()); + vo.setNeedReason(transition.getNeedReason()); + return vo; + }).collect(Collectors.toList()); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_QUERY_PERMISSION) + public ProjectRequirementLifecycleRespVO getRequirementLifecycle(Long requirementId, Long projectId) { + ProjectRequirementDO requirement = validateRequirementExists(requirementId); + validateRequirementBelongsToProject(requirement, projectId); + String currentStatus = requirement.getStatusCode(); + + ObjectStatusModelDO statusModel = statusModelMapper + .selectByObjectTypeAndStatusCodeEnabled(REQUIREMENT_OBJECT_TYPE, currentStatus); + if (statusModel == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED); + } + + ProjectRequirementLifecycleRespVO lifecycle = new ProjectRequirementLifecycleRespVO(); + lifecycle.setStatusCode(statusModel.getStatusCode()); + lifecycle.setStatusName(statusModel.getStatusName()); + lifecycle.setTerminal(statusModel.getTerminalFlag()); + lifecycle.setAllowEdit(statusModel.getAllowEdit()); + lifecycle.setLastStatusReason(requirement.getLastStatusReason()); + lifecycle.setAvailableActions(getAllowedTransitions(requirementId, projectId)); + return lifecycle; + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", + permission = PROJECT_CREATE_PERMISSION) + public Long createRequirementModule(ProjectRequirementModuleReqVO reqVO) { + validateModuleNameUnique(reqVO.getProjectId(), null, reqVO.getModuleName()); + + ProjectRequirementModuleDO module = new ProjectRequirementModuleDO(); + module.setParentId(reqVO.getParentId() != null ? reqVO.getParentId() : 0L); + module.setProjectId(reqVO.getProjectId()); + module.setModuleName(reqVO.getModuleName().trim()); + module.setRemark(normalizeNullableText(reqVO.getRemark())); + module.setIcon(normalizeNullableText(reqVO.getIcon())); + module.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0); + moduleMapper.insert(module); + return module.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#reqVO.projectId", + permission = PROJECT_UPDATE_PERMISSION) + public void updateRequirementModule(ProjectRequirementModuleReqVO reqVO) { + if (reqVO.getId() == null) { + throw invalidParamException("模块编号不能为空"); + } + ProjectRequirementModuleDO module = validateModuleExists(reqVO.getId()); + validateModuleBelongsToProject(module, reqVO.getProjectId()); + validateModuleNameUnique(reqVO.getProjectId(), reqVO.getId(), reqVO.getModuleName()); + + module.setModuleName(reqVO.getModuleName().trim()); + module.setRemark(normalizeNullableText(reqVO.getRemark())); + module.setIcon(normalizeNullableText(reqVO.getIcon())); + module.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0); + moduleMapper.updateById(module); + } + + @Override + @Transactional(rollbackFor = Exception.class) + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_DELETE_PERMISSION) + public void deleteRequirementModule(Long moduleId, Long projectId) { + ProjectRequirementModuleDO module = validateModuleExists(moduleId); + validateModuleBelongsToProject(module, projectId); + + List childModules = moduleMapper.selectListByParentId(moduleId); + if (!childModules.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_HAS_CHILDREN); + } + + List requirements = requirementMapper.selectListByModuleId(moduleId); + if (!requirements.isEmpty()) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_HAS_REQUIREMENTS); + } + moduleMapper.deleteById(moduleId); + } + + @Override + @CheckObjectPermission(objectType = PROJECT_OBJECT_TYPE, objectId = "#projectId", + permission = PROJECT_QUERY_PERMISSION) + public List getRequirementModuleTree(Long projectId) { + List modules = moduleMapper.selectListByProjectId(projectId); + return buildModuleTree(modules, 0L); + } + + @Override + public List getRequirementStatusDict() { + List statusModels = statusModelMapper.selectListByObjectType(REQUIREMENT_OBJECT_TYPE); + return statusModels.stream() + .map(this::buildStatusDictRespVO) + .collect(Collectors.toList()); + } + + @Override + public List getRequirementTerminalStatusDict() { + List statusModels = statusModelMapper.selectListByObjectType(REQUIREMENT_OBJECT_TYPE); + return statusModels.stream() + .filter(ObjectStatusModelDO::getTerminalFlag) + .map(this::buildStatusDictRespVO) + .collect(Collectors.toList()); + } + + /** + * 向上追溯需求根节点,同时收集命中路径上的全部节点。 + */ + private Long findRootRequirementIdAndCollectPath(ProjectRequirementDO requirement, + Map cache, + Set pathNodeIds) { + ProjectRequirementDO current = requirement; + while (current.getParentId() != null && current.getParentId() != 0L) { + ProjectRequirementDO parent = cache.get(current.getParentId()); + if (parent == null) { + // 父需求缺失时,保持原有容错行为:就近把当前节点视为根节点 + return current.getId(); + } + pathNodeIds.add(parent.getId()); + current = parent; + } + return current.getId(); + } + + /** + * 只构建命中搜索路径上的树节点,避免整棵树无差别返回。 + */ + private ProjectRequirementRespVO buildRequirementRespVOWithPathChildren(ProjectRequirementDO requirement, + Set pathNodeIds, + Map> childrenMap, + Map statusModelMap) { + ProjectRequirementRespVO respVO = buildRequirementRespVO(requirement, statusModelMap); + List allChildren = childrenMap.getOrDefault(requirement.getId(), Collections.emptyList()); + List pathChildren = allChildren.stream() + .filter(child -> pathNodeIds.contains(child.getId())) + .toList(); + if (!pathChildren.isEmpty()) { + respVO.setChildren(pathChildren.stream() + .map(child -> buildRequirementRespVOWithPathChildren(child, pathNodeIds, childrenMap, statusModelMap)) + .collect(Collectors.toList())); + } + return respVO; + } + + /** + * 批量补齐命中需求向上的祖先链,避免树查询逐条回库查父节点。 + */ + private Map buildRequirementCacheWithAncestors(List matchedRequirements) { + Map cache = matchedRequirements.stream() + .collect(Collectors.toMap(ProjectRequirementDO::getId, Function.identity(), (left, right) -> left, HashMap::new)); + + Set pendingParentIds = matchedRequirements.stream() + .map(ProjectRequirementDO::getParentId) + .filter(parentId -> parentId != null && parentId != 0L && !cache.containsKey(parentId)) + .collect(Collectors.toSet()); + + while (!pendingParentIds.isEmpty()) { + List parentRequirements = requirementMapper.selectBatchIds(pendingParentIds); + if (parentRequirements.isEmpty()) { + break; + } + Set nextPendingParentIds = new HashSet<>(); + for (ProjectRequirementDO parentRequirement : parentRequirements) { + cache.putIfAbsent(parentRequirement.getId(), parentRequirement); + Long parentId = parentRequirement.getParentId(); + if (parentId != null && parentId != 0L && !cache.containsKey(parentId)) { + nextPendingParentIds.add(parentId); + } + } + pendingParentIds = nextPendingParentIds; + } + return cache; + } + + /** + * 批量加载路径节点的直属子需求,后续仅在内存中组装树结构。 + */ + private Map> buildPathChildrenMap(Set pathNodeIds) { + if (pathNodeIds.isEmpty()) { + return Collections.emptyMap(); + } + List pathChildren = requirementMapper.selectList( + new LambdaQueryWrapperX() + .in(ProjectRequirementDO::getParentId, pathNodeIds) + .orderByAsc(ProjectRequirementDO::getSort) + .orderByDesc(ProjectRequirementDO::getCreateTime)); + Map> childrenMap = new HashMap<>(); + for (ProjectRequirementDO child : pathChildren) { + childrenMap.computeIfAbsent(child.getParentId(), key -> new ArrayList<>()).add(child); + } + return childrenMap; + } + + /** + * 一次性加载当前对象类型下的状态模型,供列表和树查询复用。 + */ + private Map getStatusModelMap() { + return statusModelMapper.selectListByObjectType(REQUIREMENT_OBJECT_TYPE).stream() + .collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, Function.identity(), + (left, right) -> left, LinkedHashMap::new)); + } + + /** + * 判断指定模块是否为“全部需求”根模块。 + */ + @VisibleForTesting + boolean isAllRequirementsModule(Long moduleId) { + if (moduleId == null) { + return false; + } + ProjectRequirementModuleDO module = moduleMapper.selectById(moduleId); + return module != null && module.getParentId() != null && module.getParentId() == 0L; + } + + /** + * 递归获取模块及其全部子模块 ID。 + */ + @VisibleForTesting + List getAllModuleIdsWithChildren(Long moduleId, Long projectId) { + List moduleIds = new ArrayList<>(); + moduleIds.add(moduleId); + List allModules = moduleMapper.selectListByProjectId(projectId); + collectChildModuleIds(moduleId, allModules, moduleIds); + return moduleIds; + } + + private void collectChildModuleIds(Long parentId, List allModules, List result) { + for (ProjectRequirementModuleDO module : allModules) { + if (Objects.equals(module.getParentId(), parentId)) { + result.add(module.getId()); + collectChildModuleIds(module.getId(), allModules, result); + } + } + } + + /** + * 获取需求自身及其全部后代需求。 + */ + @VisibleForTesting + List getAllRequirementsWithChildren(Long requirementId) { + ProjectRequirementDO root = validateRequirementExists(requirementId); + List result = new ArrayList<>(); + result.add(root); + collectChildRequirements(root.getId(), result); + return result; + } + + private void collectChildRequirements(Long parentId, List result) { + List children = requirementMapper.selectListByParentId(parentId); + for (ProjectRequirementDO child : children) { + result.add(child); + collectChildRequirements(child.getId(), result); + } + } + + /** + * 校验所有子需求都已处理到允许验收或关闭的状态。 + */ + @VisibleForTesting + void validateAllChildrenAllowCloseOrAccept(Long requirementId) { + List allChildren = getAllRequirementsWithChildren(requirementId); + for (ProjectRequirementDO requirement : allChildren) { + if (!Objects.equals(requirement.getId(), requirementId) + && !CHILD_ALLOW_CLOSE_STATUSES.contains(requirement.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_CHILD_NOT_ALLOW_CLOSE); + } + } + } + + /** + * 校验父需求取消时,全部子需求是否都已进入允许取消的状态集合。 + */ + @VisibleForTesting + void validateParentCancelAllowed(Long requirementId) { + List allChildren = getAllRequirementsWithChildren(requirementId); + for (ProjectRequirementDO requirement : allChildren) { + if (!Objects.equals(requirement.getId(), requirementId) + && !CHILD_ALLOW_CANCEL_STATUSES.contains(requirement.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_CHILD_NOT_ALLOW_CANCEL); + } + } + } + + /** + * 当前只对取消动作做额外过滤,避免前端展示点了也会报错的按钮。 + */ + private boolean shouldExposeTransition(ProjectRequirementDO requirement, ObjectStatusTransitionDO transition) { + if (!ACTION_CANCEL.equals(transition.getActionCode())) { + return true; + } + if (!hasChildren(requirement.getId())) { + return true; + } + return isParentCancelAllowed(requirement.getId()); + } + + /** + * 父需求存在子需求时,只有全部子需求都已取消或已拒绝,才允许展示取消动作。 + */ + private boolean isParentCancelAllowed(Long requirementId) { + List allChildren = getAllRequirementsWithChildren(requirementId); + for (ProjectRequirementDO requirement : allChildren) { + if (!Objects.equals(requirement.getId(), requirementId) + && !CHILD_ALLOW_CANCEL_STATUSES.contains(requirement.getStatusCode())) { + return false; + } + } + return true; + } + + /** + * 只要存在直接子需求,就应按父需求的取消规则处理。 + */ + private boolean hasChildren(Long requirementId) { + return !requirementMapper.selectListByParentId(requirementId).isEmpty(); + } + + /** + * 递归关闭所有已验收的子需求。 + */ + private void closeAllAcceptedChildren(Long parentId, String reason) { + List children = requirementMapper.selectListByParentId(parentId); + for (ProjectRequirementDO child : children) { + closeAllAcceptedChildren(child.getId(), reason); + if (STATUS_ACCEPTED.equals(child.getStatusCode())) { + ProjectRequirementDO before = cloneRequirement(child); + int updateCount = requirementMapper.updateStatusByIdAndStatus( + child.getId(), STATUS_ACCEPTED, STATUS_CLOSED, reason); + if (updateCount == 1) { + child.setStatusCode(STATUS_CLOSED); + child.setLastStatusReason(reason); + writeRequirementStatusLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, reason); + writeBizAuditLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, + buildRequirementFieldChanges(before, child), reason); + } + } + } + } + + /** + * 子需求状态变化后,先向上自动推导项目父需求状态,再按根需求状态回写产品需求状态。 + */ + private void refreshAncestorStatusAndSyncProduct(Long requirementId) { + autoDeriveParentStatusRecursively(requirementId); + syncRootRequirementStatusToProduct(requirementId); + } + + /** + * 仅根据直接子需求状态推导父需求状态。 + * 这里只实现约定好的几条规则,其他组合保持父需求当前状态不变。 + */ + private void autoDeriveParentStatusRecursively(Long requirementId) { + ProjectRequirementDO currentRequirement = requirementMapper.selectById(requirementId); + if (currentRequirement == null || currentRequirement.getParentId() == null + || Objects.equals(currentRequirement.getParentId(), 0L)) { + return; + } + ProjectRequirementDO parentRequirement = requirementMapper.selectById(currentRequirement.getParentId()); + if (parentRequirement == null) { + return; + } + + String targetStatus = deriveParentStatusByDirectChildren(parentRequirement.getId()); + if (!StringUtils.hasText(targetStatus) || Objects.equals(targetStatus, parentRequirement.getStatusCode())) { + return; + } + + ProjectRequirementDO before = cloneRequirement(parentRequirement); + int updateCount = requirementMapper.updateStatusByIdAndStatus(parentRequirement.getId(), + parentRequirement.getStatusCode(), targetStatus, AUTO_DERIVE_REASON); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED); + } + parentRequirement.setStatusCode(targetStatus); + parentRequirement.setLastStatusReason(AUTO_DERIVE_REASON); + + writeRequirementStatusLog(parentRequirement, ACTION_AUTO_DERIVE, before.getStatusCode(), + targetStatus, AUTO_DERIVE_REASON); + writeBizAuditLog(parentRequirement, ACTION_AUTO_DERIVE, before.getStatusCode(), + targetStatus, buildRequirementFieldChanges(before, parentRequirement), AUTO_DERIVE_REASON); + + autoDeriveParentStatusRecursively(parentRequirement.getId()); + } + + /** + * 父需求状态只在三种场景下自动变化: + * 1. 子需求全部 accepted,父需求自动 accepted; + * 2. 子需求全部 closed,父需求自动 closed; + * 3. 子需求全部 cancelled,父需求自动 cancelled; + * 4. 子需求全部处于 accepted/closed,且至少一个 accepted,父需求保持 accepted。 + */ + private String deriveParentStatusByDirectChildren(Long parentRequirementId) { + List children = requirementMapper.selectListByParentId(parentRequirementId); + if (children.isEmpty()) { + return null; + } + boolean allAccepted = children.stream() + .allMatch(child -> STATUS_ACCEPTED.equals(child.getStatusCode())); + if (allAccepted) { + return STATUS_ACCEPTED; + } + + boolean allClosed = children.stream() + .allMatch(child -> STATUS_CLOSED.equals(child.getStatusCode())); + if (allClosed) { + return STATUS_CLOSED; + } + + boolean allCancelled = children.stream() + .allMatch(child -> STATUS_CANCELLED.equals(child.getStatusCode())); + if (allCancelled) { + return STATUS_CANCELLED; + } + + boolean allAcceptedOrClosed = children.stream().allMatch(child -> + STATUS_ACCEPTED.equals(child.getStatusCode()) || STATUS_CLOSED.equals(child.getStatusCode())); + boolean hasAccepted = children.stream() + .anyMatch(child -> STATUS_ACCEPTED.equals(child.getStatusCode())); + if (allAcceptedOrClosed && hasAccepted) { + return STATUS_ACCEPTED; + } + return null; + } + + /** + * 项目需求根节点来自产品需求时,用根节点状态回写产品需求状态。 + */ + private void syncRootRequirementStatusToProduct(Long requirementId) { + ProjectRequirementDO rootRequirement = findRootRequirement(requirementId); + if (rootRequirement == null + || !SOURCE_TYPE_PRODUCT_REQUIREMENT.equals(rootRequirement.getSourceType()) + || rootRequirement.getProductRequirementId() == null) { + return; + } + + ProductRequirementDO productRequirement = productRequirementMapper.selectById(rootRequirement.getProductRequirementId()); + if (productRequirement == null || Objects.equals(productRequirement.getStatusCode(), rootRequirement.getStatusCode())) { + return; + } + + ProductRequirementDO before = cloneProductRequirement(productRequirement); + String syncReason = StringUtils.hasText(rootRequirement.getLastStatusReason()) + ? rootRequirement.getLastStatusReason() : SYNC_PRODUCT_REASON; + int updateCount = productRequirementMapper.updateStatusByIdAndStatus(productRequirement.getId(), + productRequirement.getStatusCode(), rootRequirement.getStatusCode(), syncReason); + if (updateCount != 1) { + throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED); + } + productRequirement.setStatusCode(rootRequirement.getStatusCode()); + productRequirement.setLastStatusReason(syncReason); + + writeProductRequirementStatusLog(productRequirement, ACTION_SYNC_PRODUCT_STATUS, before.getStatusCode(), + rootRequirement.getStatusCode(), syncReason); + writeProductRequirementAuditLog(productRequirement, ACTION_SYNC_PRODUCT_STATUS, before.getStatusCode(), + rootRequirement.getStatusCode(), buildProductRequirementFieldChanges(before, productRequirement), + syncReason); + } + + /** + * 沿父链回溯项目需求根节点,产品状态回写统一以根节点状态为准。 + */ + private ProjectRequirementDO findRootRequirement(Long requirementId) { + ProjectRequirementDO currentRequirement = requirementMapper.selectById(requirementId); + while (currentRequirement != null && currentRequirement.getParentId() != null + && !Objects.equals(currentRequirement.getParentId(), 0L)) { + currentRequirement = requirementMapper.selectById(currentRequirement.getParentId()); + } + return currentRequirement; + } + + private ProjectRequirementStatusDictRespVO buildStatusDictRespVO(ObjectStatusModelDO statusModel) { + ProjectRequirementStatusDictRespVO respVO = new ProjectRequirementStatusDictRespVO(); + respVO.setStatusCode(statusModel.getStatusCode()); + respVO.setStatusName(statusModel.getStatusName()); + respVO.setSort(statusModel.getSort()); + respVO.setInitialFlag(statusModel.getInitialFlag()); + respVO.setTerminalFlag(statusModel.getTerminalFlag()); + return respVO; + } + + /** + * 构建需求响应对象,不包含子需求。 + */ + private ProjectRequirementRespVO buildRequirementRespVO(ProjectRequirementDO requirement) { + return buildRequirementRespVO(requirement, getStatusModelMap()); + } + + /** + * 复用已加载的状态模型构建需求响应,避免列表场景重复查状态字典。 + */ + private ProjectRequirementRespVO buildRequirementRespVO(ProjectRequirementDO requirement, + Map statusModelMap) { + ProjectRequirementRespVO respVO = BeanUtils.toBean(requirement, ProjectRequirementRespVO.class); + ObjectStatusModelDO statusModel = statusModelMap.get(requirement.getStatusCode()); + if (statusModel != null) { + respVO.setStatusName(statusModel.getStatusName()); + respVO.setTerminal(statusModel.getTerminalFlag()); + } + if (respVO.getTerminal() == null) { + respVO.setTerminal(TERMINAL_STATUSES.contains(requirement.getStatusCode())); + } + return respVO; + } + + private List buildModuleTree(List modules, Long parentId) { + return modules.stream() + .filter(module -> Objects.equals(module.getParentId(), parentId)) + .map(module -> { + ProjectRequirementModuleRespVO vo = BeanUtils.toBean(module, ProjectRequirementModuleRespVO.class); + vo.setChildren(buildModuleTree(modules, module.getId())); + return vo; + }) + .collect(Collectors.toList()); + } + + @VisibleForTesting + ProjectRequirementDO validateRequirementExists(Long id) { + if (id == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_NOT_EXISTS); + } + ProjectRequirementDO requirement = requirementMapper.selectById(id); + if (requirement == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_NOT_EXISTS); + } + return requirement; + } + + /** + * 要求接口上传入的项目编号与需求归属项目一致,避免串项目操作。 + */ + @VisibleForTesting + void validateRequirementBelongsToProject(ProjectRequirementDO requirement, Long projectId) { + if (!Objects.equals(requirement.getProjectId(), projectId)) { + throw invalidParamException("需求不属于当前项目"); + } + } + + @VisibleForTesting + void validateRequirementEditable(ProjectRequirementDO requirement) { + if (TERMINAL_STATUSES.contains(requirement.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_EDIT); + } + } + + @VisibleForTesting + void validateParentAllowSplit(ProjectRequirementDO parentRequirement) { + if (!STATUS_IMPLEMENTING.equals(parentRequirement.getStatusCode())) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_PARENT_NOT_ALLOW_SPLIT); + } + } + + @VisibleForTesting + ObjectStatusTransitionDO validateRequirementTransition(String fromStatusCode, String actionCode) { + ObjectStatusTransitionDO transition = statusTransitionMapper + .selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode); + if (transition == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode); + } + return transition; + } + + @VisibleForTesting + void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { + if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + } + } + + @VisibleForTesting + ProjectRequirementModuleDO validateModuleExists(Long id) { + if (id == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_NOT_EXISTS); + } + ProjectRequirementModuleDO module = moduleMapper.selectById(id); + if (module == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_NOT_EXISTS); + } + return module; + } + + @VisibleForTesting + void validateModuleNameUnique(Long projectId, Long moduleId, String moduleName) { + if (!StringUtils.hasText(moduleName)) { + return; + } + ProjectRequirementModuleDO existModule = moduleMapper + .selectByProjectIdAndModuleName(projectId, moduleName.trim()); + if (existModule == null) { + return; + } + if (!existModule.getId().equals(moduleId)) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_NAME_DUPLICATE, moduleName.trim()); + } + } + + @VisibleForTesting + Long resolveModuleId(Long moduleId, Long projectId) { + if (moduleId != null) { + return moduleId; + } + ProjectRequirementModuleDO defaultModule = moduleMapper.selectByProjectIdAndParentId(projectId, 0L); + if (defaultModule == null) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_NOT_EXISTS); + } + return defaultModule.getId(); + } + + @VisibleForTesting + void validateModuleBelongsToProject(Long moduleId, Long projectId) { + ProjectRequirementModuleDO module = validateModuleExists(moduleId); + validateModuleBelongsToProject(module, projectId); + } + + @VisibleForTesting + void validateModuleBelongsToProject(ProjectRequirementModuleDO module, Long projectId) { + if (!Objects.equals(module.getProjectId(), projectId)) { + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_MODULE_NOT_BELONG_TO_PROJECT); + } + } + + private void writeRequirementStatusLog(ProjectRequirementDO requirement, String actionType, + String fromStatus, String toStatus, String reason) { + ProjectRequirementStatusLogDO statusLog = new ProjectRequirementStatusLogDO(); + statusLog.setRequirementId(requirement.getId()); + statusLog.setActionType(actionType); + statusLog.setFromStatus(fromStatus); + statusLog.setToStatus(toStatus); + statusLog.setReason(defaultText(reason)); + statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + statusLog.setRequirementTitleSnapshot(requirement.getTitle()); + statusLogMapper.insert(statusLog); + } + + private void writeBizAuditLog(ProjectRequirementDO requirement, String actionType, String fromStatus, + String toStatus, String fieldChanges, String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(BIZ_TYPE_REQUIREMENT); + auditLog.setBizId(requirement.getId()); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(defaultText(reason)); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private ProjectRequirementDO cloneRequirement(ProjectRequirementDO source) { + ProjectRequirementDO target = new ProjectRequirementDO(); + target.setId(source.getId()); + target.setParentId(source.getParentId()); + target.setProjectId(source.getProjectId()); + target.setModuleId(source.getModuleId()); + target.setProductRequirementId(source.getProductRequirementId()); + target.setReviewRequired(source.getReviewRequired()); + target.setTitle(source.getTitle()); + target.setDescription(source.getDescription()); + target.setCategory(source.getCategory()); + target.setSourceType(source.getSourceType()); + target.setSourceBizId(source.getSourceBizId()); + target.setPriority(source.getPriority()); + target.setStatusCode(source.getStatusCode()); + target.setLastStatusReason(source.getLastStatusReason()); + target.setProposerId(source.getProposerId()); + target.setProposerNickname(source.getProposerNickname()); + target.setWorkHours(source.getWorkHours()); + target.setCurrentHandlerUserId(source.getCurrentHandlerUserId()); + target.setCurrentHandlerUserNickname(source.getCurrentHandlerUserNickname()); + target.setSort(source.getSort()); + return target; + } + + /** + * 仅记录真正发生变化的字段,便于审计日志后续解析。 + */ + private String buildRequirementFieldChanges(ProjectRequirementDO before, ProjectRequirementDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "parentId", valueOf(before, ProjectRequirementDO::getParentId), + valueOf(after, ProjectRequirementDO::getParentId)); + appendFieldChange(fieldChanges, "projectId", valueOf(before, ProjectRequirementDO::getProjectId), + valueOf(after, ProjectRequirementDO::getProjectId)); + appendFieldChange(fieldChanges, "moduleId", valueOf(before, ProjectRequirementDO::getModuleId), + valueOf(after, ProjectRequirementDO::getModuleId)); + appendFieldChange(fieldChanges, "productRequirementId", valueOf(before, ProjectRequirementDO::getProductRequirementId), + valueOf(after, ProjectRequirementDO::getProductRequirementId)); + appendFieldChange(fieldChanges, "reviewRequired", valueOf(before, ProjectRequirementDO::getReviewRequired), + valueOf(after, ProjectRequirementDO::getReviewRequired)); + appendFieldChange(fieldChanges, "title", valueOf(before, ProjectRequirementDO::getTitle), + valueOf(after, ProjectRequirementDO::getTitle)); + appendFieldChange(fieldChanges, "description", valueOf(before, ProjectRequirementDO::getDescription), + valueOf(after, ProjectRequirementDO::getDescription)); + appendFieldChange(fieldChanges, "category", valueOf(before, ProjectRequirementDO::getCategory), + valueOf(after, ProjectRequirementDO::getCategory)); + appendFieldChange(fieldChanges, "sourceType", valueOf(before, ProjectRequirementDO::getSourceType), + valueOf(after, ProjectRequirementDO::getSourceType)); + appendFieldChange(fieldChanges, "sourceBizId", valueOf(before, ProjectRequirementDO::getSourceBizId), + valueOf(after, ProjectRequirementDO::getSourceBizId)); + appendFieldChange(fieldChanges, "priority", valueOf(before, ProjectRequirementDO::getPriority), + valueOf(after, ProjectRequirementDO::getPriority)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectRequirementDO::getStatusCode), + valueOf(after, ProjectRequirementDO::getStatusCode)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectRequirementDO::getLastStatusReason), + valueOf(after, ProjectRequirementDO::getLastStatusReason)); + appendFieldChange(fieldChanges, "proposerId", valueOf(before, ProjectRequirementDO::getProposerId), + valueOf(after, ProjectRequirementDO::getProposerId)); + appendFieldChange(fieldChanges, "proposerNickname", valueOf(before, ProjectRequirementDO::getProposerNickname), + valueOf(after, ProjectRequirementDO::getProposerNickname)); + appendFieldChange(fieldChanges, "workHours", valueOf(before, ProjectRequirementDO::getWorkHours), + valueOf(after, ProjectRequirementDO::getWorkHours)); + appendFieldChange(fieldChanges, "currentHandlerUserId", + valueOf(before, ProjectRequirementDO::getCurrentHandlerUserId), + valueOf(after, ProjectRequirementDO::getCurrentHandlerUserId)); + appendFieldChange(fieldChanges, "currentHandlerUserNickname", + valueOf(before, ProjectRequirementDO::getCurrentHandlerUserNickname), + valueOf(after, ProjectRequirementDO::getCurrentHandlerUserNickname)); + appendFieldChange(fieldChanges, "sort", valueOf(before, ProjectRequirementDO::getSort), + valueOf(after, ProjectRequirementDO::getSort)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + /** + * 写入产品需求状态日志,便于追踪项目需求对产品需求的回写。 + */ + private void writeProductRequirementStatusLog(ProductRequirementDO requirement, String actionType, + String fromStatus, String toStatus, String reason) { + ProductRequirementStatusLogDO statusLog = new ProductRequirementStatusLogDO(); + statusLog.setRequirementId(requirement.getId()); + statusLog.setActionType(actionType); + statusLog.setFromStatus(fromStatus); + statusLog.setToStatus(toStatus); + statusLog.setReason(defaultText(reason)); + statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + statusLog.setRequirementTitleSnapshot(requirement.getTitle()); + productRequirementStatusLogMapper.insert(statusLog); + } + + /** + * 写入产品需求审计日志,保留状态回写前后的字段变化。 + */ + private void writeProductRequirementAuditLog(ProductRequirementDO requirement, String actionType, String fromStatus, + String toStatus, String fieldChanges, String reason) { + BizAuditLogDO auditLog = new BizAuditLogDO(); + auditLog.setBizType(PRODUCT_BIZ_TYPE_REQUIREMENT); + auditLog.setBizId(requirement.getId()); + auditLog.setActionType(actionType); + auditLog.setFromStatus(fromStatus); + auditLog.setToStatus(toStatus); + auditLog.setFieldChanges(fieldChanges); + auditLog.setReason(defaultText(reason)); + auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); + auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); + bizAuditLogMapper.insert(auditLog); + } + + private ProductRequirementDO cloneProductRequirement(ProductRequirementDO source) { + ProductRequirementDO target = new ProductRequirementDO(); + target.setId(source.getId()); + target.setParentId(source.getParentId()); + target.setModuleId(source.getModuleId()); + target.setProductId(source.getProductId()); + target.setReviewRequired(source.getReviewRequired()); + target.setTitle(source.getTitle()); + target.setDescription(source.getDescription()); + target.setCategory(source.getCategory()); + target.setSourceType(source.getSourceType()); + target.setSourceBizId(source.getSourceBizId()); + target.setPriority(source.getPriority()); + target.setStatusCode(source.getStatusCode()); + target.setLastStatusReason(source.getLastStatusReason()); + target.setProposerId(source.getProposerId()); + target.setProposerNickname(source.getProposerNickname()); + target.setWorkHours(source.getWorkHours()); + target.setCurrentHandlerUserId(source.getCurrentHandlerUserId()); + target.setCurrentHandlerUserNickname(source.getCurrentHandlerUserNickname()); + target.setImplementProjectId(source.getImplementProjectId()); + target.setSort(source.getSort()); + return target; + } + + private String buildProductRequirementFieldChanges(ProductRequirementDO before, ProductRequirementDO after) { + Map fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "parentId", valueOf(before, ProductRequirementDO::getParentId), + valueOf(after, ProductRequirementDO::getParentId)); + appendFieldChange(fieldChanges, "moduleId", valueOf(before, ProductRequirementDO::getModuleId), + valueOf(after, ProductRequirementDO::getModuleId)); + appendFieldChange(fieldChanges, "productId", valueOf(before, ProductRequirementDO::getProductId), + valueOf(after, ProductRequirementDO::getProductId)); + appendFieldChange(fieldChanges, "reviewRequired", valueOf(before, ProductRequirementDO::getReviewRequired), + valueOf(after, ProductRequirementDO::getReviewRequired)); + appendFieldChange(fieldChanges, "title", valueOf(before, ProductRequirementDO::getTitle), + valueOf(after, ProductRequirementDO::getTitle)); + appendFieldChange(fieldChanges, "description", valueOf(before, ProductRequirementDO::getDescription), + valueOf(after, ProductRequirementDO::getDescription)); + appendFieldChange(fieldChanges, "category", valueOf(before, ProductRequirementDO::getCategory), + valueOf(after, ProductRequirementDO::getCategory)); + appendFieldChange(fieldChanges, "sourceType", valueOf(before, ProductRequirementDO::getSourceType), + valueOf(after, ProductRequirementDO::getSourceType)); + appendFieldChange(fieldChanges, "sourceBizId", valueOf(before, ProductRequirementDO::getSourceBizId), + valueOf(after, ProductRequirementDO::getSourceBizId)); + appendFieldChange(fieldChanges, "priority", valueOf(before, ProductRequirementDO::getPriority), + valueOf(after, ProductRequirementDO::getPriority)); + appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProductRequirementDO::getStatusCode), + valueOf(after, ProductRequirementDO::getStatusCode)); + appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProductRequirementDO::getLastStatusReason), + valueOf(after, ProductRequirementDO::getLastStatusReason)); + appendFieldChange(fieldChanges, "proposerId", valueOf(before, ProductRequirementDO::getProposerId), + valueOf(after, ProductRequirementDO::getProposerId)); + appendFieldChange(fieldChanges, "proposerNickname", valueOf(before, ProductRequirementDO::getProposerNickname), + valueOf(after, ProductRequirementDO::getProposerNickname)); + appendFieldChange(fieldChanges, "workHours", valueOf(before, ProductRequirementDO::getWorkHours), + valueOf(after, ProductRequirementDO::getWorkHours)); + appendFieldChange(fieldChanges, "currentHandlerUserId", valueOf(before, ProductRequirementDO::getCurrentHandlerUserId), + valueOf(after, ProductRequirementDO::getCurrentHandlerUserId)); + appendFieldChange(fieldChanges, "currentHandlerUserNickname", + valueOf(before, ProductRequirementDO::getCurrentHandlerUserNickname), + valueOf(after, ProductRequirementDO::getCurrentHandlerUserNickname)); + appendFieldChange(fieldChanges, "implementProjectId", valueOf(before, ProductRequirementDO::getImplementProjectId), + valueOf(after, ProductRequirementDO::getImplementProjectId)); + appendFieldChange(fieldChanges, "sort", valueOf(before, ProductRequirementDO::getSort), + valueOf(after, ProductRequirementDO::getSort)); + return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); + } + + private T valueOf(ProjectRequirementDO requirement, Function getter) { + return requirement == null ? null : getter.apply(requirement); + } + + private T valueOf(ProductRequirementDO requirement, Function getter) { + return requirement == null ? null : getter.apply(requirement); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map value = new LinkedHashMap<>(); + value.put("before", before); + value.put("after", after); + fieldChanges.put(fieldName, value); + } + + private String normalizeNullableText(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim(); + } + + private String defaultText(String value) { + return StringUtils.hasText(value) ? value : ""; + } + +}