diff --git a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java index d6b8df6..5ef157b 100644 --- a/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java +++ b/src/main/java/com/gxwebsoft/gxmu/controller/ReviewFlowController.java @@ -1,6 +1,7 @@ package com.gxwebsoft.gxmu.controller; import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator; import com.gxwebsoft.gxmu.service.ReviewFlowService; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.param.ReviewFlowParam; @@ -16,8 +17,13 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Set; /** * 审核流控制器 @@ -63,6 +69,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("添加审核流") @PostMapping() public ApiResult save(@RequestBody ReviewFlow reviewFlow) { + validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow)); // 记录当前登录用户id // ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle()); // if (checkByName != null) { @@ -77,6 +84,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("修改审核流") @PutMapping() public ApiResult update(@RequestBody ReviewFlow reviewFlow) { + validateUpdate(reviewFlow); // ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle()); // if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) { // return fail("该名称已存在"); @@ -99,6 +107,7 @@ public class ReviewFlowController extends BaseController { @ApiOperation("批量添加审核流") @PostMapping("/batch") public ApiResult saveBatch(@RequestBody List list) { + validateLevels(list); if (reviewFlowService.saveBatch(list)) { return success("添加成功"); } @@ -108,12 +117,142 @@ public class ReviewFlowController extends BaseController { @ApiOperation("批量修改审核流") @PutMapping("/batch") public ApiResult updateBatch(@RequestBody BatchParam batchParam) { + validateBatchUpdate(batchParam); if (batchParam.update(reviewFlowService, "id")) { return success("修改成功"); } return fail("修改失败"); } + /** + * 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。 + * + *

管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看, + * 否则逐级保存会被误判为缺级。 + */ + private void validateLevels(List incoming) { + if (incoming == null || incoming.isEmpty()) { + return; + } + List proposed = new ArrayList<>(); + Set titles = new LinkedHashSet<>(); + Set replacedIds = new LinkedHashSet<>(); + for (ReviewFlow flow : incoming) { + if (flow == null) { + continue; + } + proposed.add(flow); + if (flow.getId() != null) { + replacedIds.add(flow.getId()); + } + if (flow.getTitle() != null) { + titles.add(flow.getTitle().trim()); + } + } + for (String title : titles) { + List existing = reviewFlowService.listByTitle(title); + if (existing == null) { + continue; + } + for (ReviewFlow flow : existing) { + if (flow != null && flow.getId() != null && !replacedIds.contains(flow.getId())) { + proposed.add(flow); + } + } + } + ReviewFlowLevelValidator.validate(proposed); + } + + /** + * 修改单条节点:未传的字段沿用库里原值(MyBatis-Plus 同样忽略 null),再校验受影响链的连续性。 + */ + private void validateUpdate(ReviewFlow reviewFlow) { + if (reviewFlow == null) { + return; + } + ReviewFlow existing = reviewFlow.getId() == null ? null : reviewFlowService.getById(reviewFlow.getId()); + ReviewFlow merged = merge(existing, reviewFlow); + validateLevels(java.util.Collections.singletonList(merged)); + if (existing != null && !sameTitle(existing.getTitle(), merged.getTitle())) { + validateRemaining(existing.getTitle(), java.util.Collections.singletonList(reviewFlow.getId())); + } + } + + /** + * 批量修改:BatchParam 只带一个 data,id 集合里每行都按同样字段更新。 + */ + private void validateBatchUpdate(BatchParam batchParam) { + if (batchParam == null || batchParam.getData() == null + || batchParam.getIds() == null || batchParam.getIds().isEmpty()) { + return; + } + List ids = new ArrayList<>(); + for (Serializable id : batchParam.getIds()) { + if (id instanceof Number) { + ids.add(((Number) id).intValue()); + } + } + if (ids.isEmpty()) { + return; + } + List existingRows = reviewFlowService.listByIds(ids); + if (existingRows == null || existingRows.isEmpty()) { + return; + } + List mergedRows = new ArrayList<>(); + List oldTitles = new ArrayList<>(); + for (ReviewFlow existing : existingRows) { + if (existing == null) { + continue; + } + mergedRows.add(merge(existing, batchParam.getData())); + oldTitles.add(existing.getTitle()); + } + validateLevels(mergedRows); + Set newTitles = new LinkedHashSet<>(); + for (ReviewFlow row : mergedRows) { + newTitles.add(row.getTitle() == null ? "" : row.getTitle().trim()); + } + for (String oldTitle : oldTitles) { + if (!newTitles.contains(oldTitle == null ? "" : oldTitle.trim())) { + validateRemaining(oldTitle, ids); + } + } + } + + /** + * 校验改名/移走后原审批链剩下的节点仍连续。 + */ + private void validateRemaining(String title, Collection excludedIds) { + List remaining = new ArrayList<>(); + List existing = title == null ? null : reviewFlowService.listByTitle(title); + if (existing != null) { + for (ReviewFlow flow : existing) { + if (flow != null && !excludedIds.contains(flow.getId())) { + remaining.add(flow); + } + } + } + ReviewFlowLevelValidator.validate(remaining); + } + + private ReviewFlow merge(ReviewFlow existing, ReviewFlow incoming) { + if (existing == null) { + return incoming; + } + if (incoming.getTitle() != null) { + existing.setTitle(incoming.getTitle()); + } + if (incoming.getLevel() != null) { + existing.setLevel(incoming.getLevel()); + } + return existing; + } + + private boolean sameTitle(String left, String right) { + return Objects.equals(left == null ? null : left.trim(), right == null ? null : right.trim()); + } + @ApiOperation("批量删除审核流") @DeleteMapping("/batch") public ApiResult removeBatch(@RequestBody List ids) { diff --git a/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java new file mode 100644 index 0000000..ad4b30c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidator.java @@ -0,0 +1,81 @@ +package com.gxwebsoft.gxmu.service; + +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.gxmu.entity.ReviewFlow; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。 + * + *

一条审批链的层级必须恰好是 1..N,缺级会让申报卡在半路,重复级会让两个人拿到同一条待办。 + * 按 title 分组校验,纯函数,不依赖数据库。 + * + * @author Codex + * @since 2026-11-03 + */ +public final class ReviewFlowLevelValidator { + + private ReviewFlowLevelValidator() { + } + + /** + * 校验一组审批流节点的层级;不合法时抛出 {@link BusinessException}。 + * + * @param flows 同一轮保存涉及的节点,可包含多条审批链(按 title 分组) + */ + public static void validate(List flows) { + if (flows == null || flows.isEmpty()) { + return; + } + Map> levelsByTitle = new LinkedHashMap<>(); + for (ReviewFlow flow : flows) { + if (flow == null) { + continue; + } + levelsByTitle.computeIfAbsent(titleOf(flow), key -> new ArrayList<>()).add(flow.getLevel()); + } + for (Map.Entry> entry : levelsByTitle.entrySet()) { + validateOne(entry.getKey(), entry.getValue()); + } + } + + private static void validateOne(String title, List levels) { + for (Integer level : levels) { + if (level == null) { + throw new BusinessException("审批链「" + displayTitle(title) + "」存在未填写层级的节点,层级必须从 1 连续升序"); + } + } + List sorted = new ArrayList<>(levels); + Collections.sort(sorted); + for (int index = 0; index < sorted.size(); index++) { + if (sorted.get(index) != index + 1) { + throw new BusinessException("审批链「" + displayTitle(title) + "」的层级必须从 1 连续升序,当前层级为 " + + join(sorted) + ",请调整后再保存"); + } + } + } + + private static String titleOf(ReviewFlow flow) { + return flow.getTitle() == null ? "" : flow.getTitle().trim(); + } + + private static String displayTitle(String title) { + return title.isEmpty() ? "未命名" : title; + } + + private static String join(List levels) { + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < levels.size(); index++) { + if (index > 0) { + builder.append(", "); + } + builder.append(levels.get(index)); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java index 660fa01..dd44440 100644 --- a/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java +++ b/src/main/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImpl.java @@ -1,5 +1,6 @@ package com.gxwebsoft.gxmu.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.common.core.exception.BusinessException; import com.gxwebsoft.gxmu.entity.ReviewFlow; import com.gxwebsoft.gxmu.entity.ReviewFlowConfig; @@ -11,6 +12,7 @@ import com.gxwebsoft.gxmu.service.ReviewFlowStarterService; import com.gxwebsoft.gxmu.service.ReviewListService; import com.gxwebsoft.gxmu.service.ReviewerResolver; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.time.LocalDateTime; @@ -68,7 +70,12 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { saveFirstReviewNode(module, pk, firstOne); } + /** + * 驳回后重走审批链:先清掉同一申报记录下的旧节点,再生成从第一级开始的新节点, + * 保证任一时刻只有一组活跃节点(spec #58 行为约定)。判定"上一条是驳回"必须在删除前完成。 + */ @Override + @Transactional(rollbackFor = Exception.class) public void restartReviewWhenLastRejected(String module, Integer pk) { if (module == null || pk == null) { return; @@ -94,6 +101,9 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService { if (firstOne == null) { return; } + reviewListService.remove(new LambdaQueryWrapper() + .eq(ReviewList::getModule, module) + .eq(ReviewList::getPk, pk)); saveFirstReviewNode(module, pk, firstOne); } diff --git a/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java new file mode 100644 index 0000000..49888ce --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/controller/ReviewFlowControllerLevelTest.java @@ -0,0 +1,77 @@ +package com.gxwebsoft.gxmu.controller; + +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.gxmu.entity.ReviewFlow; +import com.gxwebsoft.gxmu.service.ReviewFlowService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 审批链保存入口的层级校验接线(spec #58)。 + * + *

管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 title 的已有节点判断, + * 否则逐级保存会被误判为缺级;这里钉住这个接线。 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReviewFlowControllerLevelTest { + + private static final String TITLE = "十佳青年"; + + @Mock + private ReviewFlowService reviewFlowService; + + @InjectMocks + private ReviewFlowController controller; + + private static ReviewFlow flow(Integer id, String title, Integer level) { + ReviewFlow flow = new ReviewFlow(); + flow.setId(id); + flow.setTitle(title); + flow.setLevel(level); + return flow; + } + + @Test + void savingNextLevelAfterPreviousOneIsAccepted() { + when(reviewFlowService.listByTitle(TITLE)) + .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + when(reviewFlowService.save(any())).thenReturn(true); + + controller.save(flow(null, TITLE, 2)); + + verify(reviewFlowService).save(any()); + } + + @Test + void savingLevelThreeWithoutTwoIsRejectedBeforeWrite() { + when(reviewFlowService.listByTitle(TITLE)) + .thenReturn(Collections.singletonList(flow(1, TITLE, 1))); + + assertThrows(BusinessException.class, () -> controller.save(flow(null, TITLE, 3))); + verify(reviewFlowService, never()).save(any()); + } + + @Test + void batchSaveRejectsGap() { + when(reviewFlowService.listByTitle(TITLE)).thenReturn(Collections.emptyList()); + + assertThrows(BusinessException.class, () -> controller.saveBatch(Arrays.asList( + flow(null, TITLE, 1), flow(null, TITLE, 3)))); + verify(reviewFlowService, never()).saveBatch(any()); + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidatorTest.java b/src/test/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidatorTest.java new file mode 100644 index 0000000..8b32c6e --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/service/ReviewFlowLevelValidatorTest.java @@ -0,0 +1,84 @@ +package com.gxwebsoft.gxmu.service; + +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.gxmu.entity.ReviewFlow; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 审批链层级连续性校验(spec #58 行为约定:层级从 1 连续升序)。 + * + *

层级是审批顺序的唯一依据:缺一级会让申报卡在半路,重复一级会让两个人同时看到同一条待办。 + * 这里钉住"保存前就能拒绝"的行为,不依赖数据库。 + */ +class ReviewFlowLevelValidatorTest { + + private static ReviewFlow flow(String title, Integer level) { + ReviewFlow flow = new ReviewFlow(); + flow.setTitle(title); + flow.setLevel(level); + return flow; + } + + @Test + void contiguousLevelsAreAccepted() { + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("十佳青年", 3), flow("十佳青年", 1), flow("十佳青年", 2))); + } + + @Test + void gapIsRejected() { + BusinessException error = assertThrows(BusinessException.class, () -> + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("十佳青年", 1), flow("十佳青年", 3), flow("十佳青年", 4)))); + assertTrue(error.getMessage().contains("十佳青年"), "报错要指出是哪条审批链:" + error.getMessage()); + assertTrue(error.getMessage().contains("1, 3, 4"), "报错要列出实际层级:" + error.getMessage()); + } + + @Test + void duplicateLevelIsRejected() { + assertThrows(BusinessException.class, () -> + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("挑战杯培育", 1), flow("挑战杯培育", 2), flow("挑战杯培育", 2)))); + } + + @Test + void chainMustStartAtOne() { + assertThrows(BusinessException.class, () -> + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("青马工程", 2), flow("青马工程", 3)))); + } + + @Test + void missingLevelIsRejected() { + assertThrows(BusinessException.class, () -> + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("未来学术之星", 1), flow("未来学术之星", null)))); + } + + @Test + void titlesAreValidatedIndependently() { + BusinessException error = assertThrows(BusinessException.class, () -> + ReviewFlowLevelValidator.validate(Arrays.asList( + flow("甲链", 1), flow("甲链", 2), + flow("乙链", 1), flow("乙链", 3)))); + assertTrue(error.getMessage().contains("乙链"), error.getMessage()); + } + + @Test + void emptyChainNeedsNoValidation() { + ReviewFlowLevelValidator.validate(null); + ReviewFlowLevelValidator.validate(Collections.emptyList()); + } + + @Test + void singleLevelOneIsAccepted() { + ReviewFlowLevelValidator.validate(Collections.singletonList(flow("单级链", 1))); + } +} diff --git a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java index 4f30000..35e0fbe 100644 --- a/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java +++ b/src/test/java/com/gxwebsoft/gxmu/service/impl/ReviewFlowStarterServiceImplTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -28,18 +29,17 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 审批流发起测试(mock 掉审批链配置、待办与申报记录读取,不启动 Spring 容器、不连数据库)。 + * 审批流发起与驳回重走(spec #58)。 * - *

接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法。断言「写进待办的人是谁」 - * 与「解析不出来时一个节点都不写」。

+ *

接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。 + * 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。

* * @author Codex * @since 2026-11-10 @@ -49,7 +49,7 @@ import static org.mockito.Mockito.when; class ReviewFlowStarterServiceImplTest { private static final String MODULE = "gxmu_sjqn"; - private static final Integer PK = 5; + private static final Integer PK = 11; private static final Integer COLLEGE_ID = 100; private static final Integer DECLARE_USER_ID = 12; @@ -75,29 +75,31 @@ class ReviewFlowStarterServiceImplTest { void setUp() { ReviewFlowConfig config = new ReviewFlowConfig(); config.setModule(MODULE); - config.setFlowId(11); + config.setFlowId(3); when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config); - ReviewFlow root = new ReviewFlow(); - root.setId(11); - root.setTitle("十佳青年"); - when(reviewFlowService.getById(11)).thenReturn(root); - - ReviewFlow first = new ReviewFlow(); - first.setId(11); - first.setTitle("十佳青年"); - first.setLevel(1); - first.setReviewerRule("USER:42"); - when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Collections.singletonList(first)); + ReviewFlow root = flow("十佳青年", null, 99); + root.setId(3); + when(reviewFlowService.getById(3)).thenReturn(root); + when(reviewFlowService.listByTitle("十佳青年")) + .thenReturn(Collections.singletonList(flow("十佳青年", 1, 7))); when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext()); - when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Arrays.asList(42, 43)); + when(declareRecordMapper.selectRecord(MODULE, PK)).thenReturn(recordContext()); + // 默认:解析出第一级审批人 7 + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(7)); } + // ------------------------------------------------------------------ + // 发起:审批人来自解析 + // ------------------------------------------------------------------ + /** 发起:待办的责任人来自解析结果(按 userId 升序取第一个),且带上模块、主键与顺序号。 */ @Test void startReview_writesResolvedReviewerAsFirstNode() { + when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Arrays.asList(42, 43)); + reviewFlowStarterService.startReview(MODULE, PK); ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); @@ -134,16 +136,38 @@ class ReviewFlowStarterServiceImplTest { verify(reviewListService, never()).save(any()); } + // ------------------------------------------------------------------ + // 驳回重走:先清旧节点,再解析第一级 + // ------------------------------------------------------------------ + + @Test + void rejectedRecordClearsOldNodesBeforeRestarting() { + LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0); + when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Arrays.asList( + reviewNode(90, 1, rejectedAt.minusDays(2)), + reviewNode(91, 2, rejectedAt))); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + InOrder order = inOrder(reviewListService); + order.verify(reviewListService).remove(any()); + order.verify(reviewListService).save(any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReviewList.class); + verify(reviewListService).save(captor.capture()); + ReviewList saved = captor.getValue(); + assertEquals(MODULE, saved.getModule()); + assertEquals(PK, saved.getPk()); + assertEquals(Integer.valueOf(7), saved.getUserId(), "新节点应落在重新解析出的第一级审批人上"); + assertEquals(Integer.valueOf(0), saved.getSortNumber()); + } + /** 驳回后重提:重新解析审批人,不复制旧的 review_user_id。 */ @Test void restartReviewWhenLastRejected_resolvesReviewerAgain() { - ReviewList rejected = new ReviewList(); - rejected.setId(3); - rejected.setModule(MODULE); - rejected.setPk(PK); - rejected.setStatus(2); - rejected.setCreateTime(LocalDateTime.of(2026, 11, 1, 10, 0)); - when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.singletonList(rejected)); + LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0); + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 2, rejectedAt))); when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43)); reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); @@ -153,6 +177,59 @@ class ReviewFlowStarterServiceImplTest { assertEquals(Integer.valueOf(43), captor.getValue().getUserId()); } + @Test + void approvedRecordDoesNotRestart() { + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 1, LocalDateTime.now()))); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } + + @Test + void recordWithoutNodesIsLeftAlone() { + when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList()); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } + + @Test + void missingFlowConfigDoesNotTouchNodes() { + when(reviewListService.listByModuleNPK(MODULE, PK)) + .thenReturn(Collections.singletonList(reviewNode(91, 2, LocalDateTime.now()))); + when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(null); + + reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK); + + verify(reviewListService, never()).remove(any()); + verify(reviewListService, never()).save(any()); + } + + private ReviewList reviewNode(Integer id, Integer status, LocalDateTime createTime) { + ReviewList node = new ReviewList(); + node.setId(id); + node.setModule(MODULE); + node.setPk(PK); + node.setUserId(5); + node.setStatus(status); + node.setSortNumber(1); + node.setCreateTime(createTime); + return node; + } + + private ReviewFlow flow(String title, Integer level, Integer reviewUserId) { + ReviewFlow flow = new ReviewFlow(); + flow.setTitle(title); + flow.setLevel(level); + flow.setReviewUserId(reviewUserId); + return flow; + } + private Map recordContext() { Map record = new HashMap<>(); record.put("id", PK);