diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java b/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java index e628212..96fb08f 100644 --- a/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java +++ b/backend/src/main/java/com/xly/erp/module/mod/service/ModuleService.java @@ -1,10 +1,14 @@ package com.xly.erp.module.mod.service; import com.xly.erp.module.mod.dto.ModuleCreateDTO; +import com.xly.erp.module.mod.dto.ModuleQueryDTO; import com.xly.erp.module.mod.dto.ModuleUpdateDTO; import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; import com.xly.erp.module.mod.vo.ModuleVO; +import java.util.List; + public interface ModuleService { /** REQ-MOD-001 模块新增 */ ModuleVO create(ModuleCreateDTO dto); @@ -14,4 +18,7 @@ public interface ModuleService { /** REQ-MOD-003 模块软删除 */ ModuleDeleteResultVO delete(Integer id); + + /** REQ-MOD-004 模块树查询(可选 keyword 模糊匹配 + 祖先链) */ + List tree(ModuleQueryDTO query); } diff --git a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java index b331c6b..9529b85 100644 --- a/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java +++ b/backend/src/main/java/com/xly/erp/module/mod/service/impl/ModuleServiceImpl.java @@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.xly.erp.common.exception.BizException; import com.xly.erp.common.response.ErrorCode; import com.xly.erp.module.mod.dto.ModuleCreateDTO; +import com.xly.erp.module.mod.dto.ModuleQueryDTO; import com.xly.erp.module.mod.dto.ModuleUpdateDTO; import com.xly.erp.module.mod.entity.ModuleEntity; import com.xly.erp.module.mod.mapper.ModuleMapper; import com.xly.erp.module.mod.service.ModuleService; import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; import com.xly.erp.module.mod.vo.ModuleVO; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; @@ -17,6 +19,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -165,4 +175,84 @@ public class ModuleServiceImpl implements ModuleService { return ModuleDeleteResultVO.of(id, true); } + + /** REQ-MOD-004 模块树查询 */ + @Override + @Transactional(readOnly = true) + public List tree(ModuleQueryDTO query) { + // 1. 拉取所有未软删除模块 + List all = moduleMapper.selectList( + new LambdaQueryWrapper().eq(ModuleEntity::getBDeleted, false)); + if (all.isEmpty()) { + return new ArrayList<>(); + } + + // 2. id → entity 索引 + Map byId = all.stream() + .collect(Collectors.toMap(ModuleEntity::getIIncrement, e -> e)); + + // 3. 计算 survivors 集合 + String keyword = query == null ? null : query.getKeyword(); + Set survivorIds; + if (keyword == null || keyword.isEmpty()) { + survivorIds = byId.keySet(); + } else { + survivorIds = new HashSet<>(); + // 命中节点 + List hits = all.stream() + .filter(e -> e.getSModuleNameZh() != null && e.getSModuleNameZh().contains(keyword)) + .toList(); + for (ModuleEntity hit : hits) { + survivorIds.add(hit.getIIncrement()); + // 沿父链向上收集祖先(深度上限 5) + Integer parentId = hit.getIParentId(); + int depth = 0; + while (parentId != null && depth < 5) { + if (!survivorIds.add(parentId)) { + break; // 已存在,提前结束避免循环 + } + ModuleEntity parent = byId.get(parentId); + if (parent == null) break; + parentId = parent.getIParentId(); + depth++; + } + } + if (survivorIds.isEmpty()) { + return new ArrayList<>(); + } + } + + // 4. 转 VO + 按 (iSortOrder, iIncrement) 排序 + Comparator cmp = Comparator + .comparingInt((ModuleTreeNodeVO v) -> v.getISortOrder() == null ? 0 : v.getISortOrder()) + .thenComparingInt(ModuleTreeNodeVO::getIIncrement); + + Map nodeById = new HashMap<>(); + for (Integer id : survivorIds) { + ModuleEntity e = byId.get(id); + if (e != null) { + nodeById.put(id, ModuleTreeNodeVO.from(e)); + } + } + + // 5. 挂 children + 收集 roots + List roots = new ArrayList<>(); + for (ModuleTreeNodeVO node : nodeById.values()) { + Integer pid = node.getIParentId(); + if (pid == null || !nodeById.containsKey(pid)) { + // 父不存在于 survivors(被过滤掉或本身就是根)→ 提为根 + roots.add(node); + } else { + nodeById.get(pid).getChildren().add(node); + } + } + + // 6. 排序:根节点 + 每个节点的 children + roots.sort(cmp); + for (ModuleTreeNodeVO node : nodeById.values()) { + node.getChildren().sort(cmp); + } + + return roots; + } } diff --git a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java index 9b5f005..60aec2f 100644 --- a/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java +++ b/backend/src/test/java/com/xly/erp/module/mod/service/ModuleServiceImplTest.java @@ -4,11 +4,13 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.xly.erp.common.exception.BizException; import com.xly.erp.common.response.ErrorCode; import com.xly.erp.module.mod.dto.ModuleCreateDTO; +import com.xly.erp.module.mod.dto.ModuleQueryDTO; import com.xly.erp.module.mod.dto.ModuleUpdateDTO; import com.xly.erp.module.mod.entity.ModuleEntity; import com.xly.erp.module.mod.mapper.ModuleMapper; import com.xly.erp.module.mod.service.impl.ModuleServiceImpl; import com.xly.erp.module.mod.vo.ModuleDeleteResultVO; +import com.xly.erp.module.mod.vo.ModuleTreeNodeVO; import com.xly.erp.module.mod.vo.ModuleVO; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -20,6 +22,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -391,4 +396,111 @@ class ModuleServiceImplTest { .extracting(e -> ((BizException) e).getCode()) .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); } + + // ============================================================ + // REQ-MOD-004 tree 系列 + // ============================================================ + + private ModuleEntity buildModule(int id, Integer parentId, String name, int sortOrder) { + ModuleEntity e = new ModuleEntity(); + e.setIIncrement(id); + e.setIParentId(parentId); + e.setSModuleNameZh(name); + e.setSDisplayType("前端业务"); + e.setSManageDeptEn("IT"); + e.setSProcedureName("sp_" + id); + e.setSModuleType("MOD"); + e.setBShowPermission(false); + e.setISortOrder(sortOrder); + e.setBDeleted(false); + e.setTCreateDate(LocalDateTime.now()); + return e; + } + + @Test + void tree_emptyDb_returnsEmptyList() { + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); + List result = service.tree(new ModuleQueryDTO()); + assertThat(result).isEmpty(); + } + + @Test + void tree_singleRoot_returnsOneNodeWithEmptyChildren() { + when(moduleMapper.selectList(any(Wrapper.class))) + .thenReturn(Arrays.asList(buildModule(1, null, "系统配置", 0))); + + List result = service.tree(new ModuleQueryDTO()); + assertThat(result).hasSize(1); + assertThat(result.get(0).getIIncrement()).isEqualTo(1); + assertThat(result.get(0).getChildren()).isNotNull().isEmpty(); + } + + @Test + void tree_multiLevel_buildsNestedStructureSortedByISortOrder() { + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( + buildModule(1, null, "RootB", 2), + buildModule(2, null, "RootA", 1), + buildModule(3, 1, "ChildOfB", 0) + )); + + List result = service.tree(new ModuleQueryDTO()); + + // 根节点按 sortOrder 升序:RootA (sort=1) 在前,RootB (sort=2) 在后 + assertThat(result).hasSize(2); + assertThat(result.get(0).getIIncrement()).isEqualTo(2); // RootA + assertThat(result.get(1).getIIncrement()).isEqualTo(1); // RootB + // RootB 的 children 含 ChildOfB + assertThat(result.get(1).getChildren()).hasSize(1); + assertThat(result.get(1).getChildren().get(0).getIIncrement()).isEqualTo(3); + // RootA 是叶子 + assertThat(result.get(0).getChildren()).isEmpty(); + } + + @Test + void tree_keywordHit_includesAncestorChain() { + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( + buildModule(1, null, "系统配置", 0), + buildModule(2, 1, "用户管理", 0), + buildModule(3, 2, "登录", 0), + buildModule(99, null, "无关模块", 0) + )); + + ModuleQueryDTO q = new ModuleQueryDTO(); + q.setKeyword("登录"); + List result = service.tree(q); + + // 应返回 1 → 2 → 3 三层链;不含 99 + assertThat(result).hasSize(1); + ModuleTreeNodeVO root = result.get(0); + assertThat(root.getIIncrement()).isEqualTo(1); + assertThat(root.getChildren()).hasSize(1); + ModuleTreeNodeVO mid = root.getChildren().get(0); + assertThat(mid.getIIncrement()).isEqualTo(2); + assertThat(mid.getChildren()).hasSize(1); + assertThat(mid.getChildren().get(0).getIIncrement()).isEqualTo(3); + } + + @Test + void tree_keywordNoMatch_returnsEmptyList() { + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList( + buildModule(1, null, "系统配置", 0), + buildModule(2, null, "权限分配", 0) + )); + + ModuleQueryDTO q = new ModuleQueryDTO(); + q.setKeyword("不存在的关键词"); + List result = service.tree(q); + assertThat(result).isEmpty(); + } + + @Test + void tree_softDeletedExcluded_passesBDeletedZeroToMapper() { + when(moduleMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList()); + + service.tree(new ModuleQueryDTO()); + + // 验证调用了 selectList 一次(mapper 端用 wrapper.eq(bDeleted, false) 已是 service 实现细节, + // 这里仅验证 service 调用了 selectList,wrapper 形状由 IT 在真实 DB 上 cross-check) + verify(moduleMapper).selectList(any(Wrapper.class)); + } }