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 d893d62..f482362 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,8 +1,13 @@ package com.xly.erp.module.mod.service; import com.xly.erp.module.mod.dto.ModuleCreateDTO; +import com.xly.erp.module.mod.dto.ModuleUpdateDTO; import com.xly.erp.module.mod.vo.ModuleVO; public interface ModuleService { + /** REQ-MOD-001 模块新增 */ ModuleVO create(ModuleCreateDTO dto); + + /** REQ-MOD-002 模块修改 */ + ModuleVO update(Integer id, ModuleUpdateDTO dto); } 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 4e13c69..fcc27af 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 @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.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; @@ -65,4 +66,62 @@ public class ModuleServiceImpl implements ModuleService { // 5. 返回 VO return ModuleVO.from(e); } + + /** REQ-MOD-002 模块修改 */ + @Override + @Transactional(rollbackFor = Exception.class) + public ModuleVO update(Integer id, ModuleUpdateDTO dto) { + // 1. 目标模块校验 + ModuleEntity target = moduleMapper.selectById(id); + if (target == null || Boolean.TRUE.equals(target.getBDeleted())) { + throw new BizException(ErrorCode.MOD_NOT_FOUND); + } + + // 2. iParentId 校验 + Integer newParentId = dto.getIParentId(); + if (newParentId != null) { + // 2a. 自引用 + if (newParentId.equals(id)) { + throw new BizException(ErrorCode.MOD_PARENT_LOOP); + } + // 2b. 父存在性 + ModuleEntity parent = moduleMapper.selectById(newParentId); + if (parent == null || Boolean.TRUE.equals(parent.getBDeleted())) { + throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); + } + // 2c. 环路检查:从 newParentId 沿父链 walk up,最多 5 层;遇到 id 即环路 + ModuleEntity cur = parent; + int depth = 1; + while (cur != null && cur.getIParentId() != null && depth <= 5) { + if (cur.getIParentId().equals(id)) { + throw new BizException(ErrorCode.MOD_PARENT_LOOP); + } + ModuleEntity next = moduleMapper.selectById(cur.getIParentId()); + if (next == null || Boolean.TRUE.equals(next.getBDeleted())) { + break; + } + cur = next; + depth++; + } + } + + // 3. 字段合并到 target(sProcedureName / iIncrement / tCreateDate / sCreatedBy / 多租户字段 / bDeleted 等保留原值) + target.setSDisplayType(dto.getSDisplayType()); + target.setSModuleType(dto.getSModuleType()); + target.setSManageDeptEn(dto.getSManageDeptEn()); + target.setSModuleNameZh(dto.getSModuleNameZh()); + if (dto.getBShowPermission() != null) { + target.setBShowPermission(dto.getBShowPermission()); + } + target.setIParentId(dto.getIParentId()); // null 设根 + if (dto.getISortOrder() != null) { + target.setISortOrder(dto.getISortOrder()); + } + + // 4. 落库 + moduleMapper.updateById(target); + + // 5. 返回 VO + return ModuleVO.from(target); + } } 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 f3ae5a6..eff23ba 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,18 +4,22 @@ 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.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.ModuleVO; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; +import java.time.LocalDateTime; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -132,4 +136,177 @@ class ModuleServiceImplTest { .extracting(e -> ((BizException) e).getCode()) .isEqualTo(ErrorCode.MOD_PROC_NAME_DUP.getCode()); } + + // ============================================================ + // REQ-MOD-002 update 系列 + // ============================================================ + + private ModuleUpdateDTO updateDto() { + ModuleUpdateDTO d = new ModuleUpdateDTO(); + d.setSDisplayType("系统配置"); + d.setSModuleType("USR_REVISED"); + d.setSManageDeptEn("OPS"); + d.setBShowPermission(true); + d.setSModuleNameZh("用户管理(修订)"); + d.setIParentId(null); + d.setISortOrder(5); + return d; + } + + private ModuleEntity existingTarget(int id) { + ModuleEntity t = new ModuleEntity(); + t.setIIncrement(id); + t.setSDisplayType("前端业务"); + t.setSProcedureName("sp_audit_existing"); + t.setSModuleType("USR"); + t.setSManageDeptEn("IT"); + t.setBShowPermission(false); + t.setSModuleNameZh("用户管理"); + t.setIParentId(null); + t.setISortOrder(0); + t.setBDeleted(false); + t.setTCreateDate(LocalDateTime.of(2026, 1, 1, 0, 0)); + t.setSCreatedBy("admin"); + return t; + } + + @Test + void update_targetNotFound_throws40421() { + when(moduleMapper.selectById(10)).thenReturn(null); + + assertThatThrownBy(() -> service.update(10, updateDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); + } + + @Test + void update_targetSoftDeleted_throws40421() { + ModuleEntity t = existingTarget(11); + t.setBDeleted(true); + when(moduleMapper.selectById(11)).thenReturn(t); + + assertThatThrownBy(() -> service.update(11, updateDto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.MOD_NOT_FOUND.getCode()); + } + + @Test + void update_parentSelfReference_throws40921() { + ModuleEntity t = existingTarget(12); + when(moduleMapper.selectById(12)).thenReturn(t); + ModuleUpdateDTO d = updateDto(); + d.setIParentId(12); + + assertThatThrownBy(() -> service.update(12, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode()); + } + + @Test + void update_parentNotFound_throws40411() { + ModuleEntity t = existingTarget(13); + when(moduleMapper.selectById(13)).thenReturn(t); + when(moduleMapper.selectById(999999)).thenReturn(null); + ModuleUpdateDTO d = updateDto(); + d.setIParentId(999999); + + assertThatThrownBy(() -> service.update(13, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.MOD_PARENT_NOT_FOUND.getCode()); + } + + @Test + void update_parentIsDescendant_throws40921() { + // 三层结构: 14(target) <- 20(child) <- 30(grand) + ModuleEntity target = existingTarget(14); + ModuleEntity child = existingTarget(20); + child.setIParentId(14); + ModuleEntity grand = existingTarget(30); + grand.setIParentId(20); + + when(moduleMapper.selectById(14)).thenReturn(target); + when(moduleMapper.selectById(30)).thenReturn(grand); + when(moduleMapper.selectById(20)).thenReturn(child); + + ModuleUpdateDTO d = updateDto(); + d.setIParentId(30); // 想把 14 的父设成 30,但 30 是 14 的孙子 → 环路 + + assertThatThrownBy(() -> service.update(14, d)) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.MOD_PARENT_LOOP.getCode()); + } + + @Test + void update_full_returnsVOWithUpdatedFields() { + ModuleEntity target = existingTarget(15); + when(moduleMapper.selectById(15)).thenReturn(target); + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); + + ModuleVO vo = service.update(15, updateDto()); + + ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class); + verify(moduleMapper).updateById(cap.capture()); + ModuleEntity saved = cap.getValue(); + + // 已修改字段 + assertThat(saved.getSDisplayType()).isEqualTo("系统配置"); + assertThat(saved.getSModuleType()).isEqualTo("USR_REVISED"); + assertThat(saved.getSManageDeptEn()).isEqualTo("OPS"); + assertThat(saved.getSModuleNameZh()).isEqualTo("用户管理(修订)"); + assertThat(saved.getBShowPermission()).isTrue(); + assertThat(saved.getISortOrder()).isEqualTo(5); + assertThat(saved.getIParentId()).isNull(); + // 保持原值 + assertThat(saved.getIIncrement()).isEqualTo(15); + assertThat(saved.getSProcedureName()).isEqualTo("sp_audit_existing"); + assertThat(saved.getTCreateDate()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0)); + assertThat(saved.getSCreatedBy()).isEqualTo("admin"); + assertThat(saved.getBDeleted()).isFalse(); + + assertThat(vo.getSDisplayType()).isEqualTo("系统配置"); + assertThat(vo.getSProcedureName()).isEqualTo("sp_audit_existing"); + } + + @Test + void update_partialNullFields_keepsOriginalValues() { + ModuleEntity target = existingTarget(16); + target.setBShowPermission(true); + target.setISortOrder(99); + when(moduleMapper.selectById(16)).thenReturn(target); + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); + + ModuleUpdateDTO d = updateDto(); + d.setBShowPermission(null); + d.setISortOrder(null); + + service.update(16, d); + + ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class); + verify(moduleMapper).updateById(cap.capture()); + ModuleEntity saved = cap.getValue(); + assertThat(saved.getBShowPermission()).isTrue(); // 原值保留 + assertThat(saved.getISortOrder()).isEqualTo(99); // 原值保留 + } + + @Test + void update_clearParent_setsParentToNull() { + ModuleEntity target = existingTarget(17); + target.setIParentId(7); // 原本有父 + when(moduleMapper.selectById(17)).thenReturn(target); + when(moduleMapper.updateById((ModuleEntity) any())).thenReturn(1); + + ModuleUpdateDTO d = updateDto(); + d.setIParentId(null); // 显式清空 + + service.update(17, d); + + ArgumentCaptor cap = ArgumentCaptor.forClass(ModuleEntity.class); + verify(moduleMapper).updateById(cap.capture()); + assertThat(cap.getValue().getIParentId()).isNull(); + } }