merge: #62 驳回清理旧节点与审批链层级校验
冲突解决:ReviewFlowStarterServiceImpl 保留 #62 的驳回清理与事务注解, 发起/重提仍由 ReviewerResolver 解析取人;合并两份 ReviewFlowStarterServiceImplTest, 置于同一文件(驳回清理 4 个用例 + 审批人解析 4 个用例)。
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft.gxmu.controller;
|
package com.gxwebsoft.gxmu.controller;
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.web.BaseController;
|
import com.gxwebsoft.common.core.web.BaseController;
|
||||||
|
import com.gxwebsoft.gxmu.service.ReviewFlowLevelValidator;
|
||||||
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
import com.gxwebsoft.gxmu.service.ReviewFlowService;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
|
import com.gxwebsoft.gxmu.param.ReviewFlowParam;
|
||||||
@@ -16,8 +17,13 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
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.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核流控制器
|
* 审核流控制器
|
||||||
@@ -63,6 +69,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("添加审核流")
|
@ApiOperation("添加审核流")
|
||||||
@PostMapping()
|
@PostMapping()
|
||||||
public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) {
|
public ApiResult<?> save(@RequestBody ReviewFlow reviewFlow) {
|
||||||
|
validateLevels(reviewFlow == null ? null : java.util.Collections.singletonList(reviewFlow));
|
||||||
// 记录当前登录用户id
|
// 记录当前登录用户id
|
||||||
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
||||||
// if (checkByName != null) {
|
// if (checkByName != null) {
|
||||||
@@ -77,6 +84,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("修改审核流")
|
@ApiOperation("修改审核流")
|
||||||
@PutMapping()
|
@PutMapping()
|
||||||
public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) {
|
public ApiResult<?> update(@RequestBody ReviewFlow reviewFlow) {
|
||||||
|
validateUpdate(reviewFlow);
|
||||||
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
// ReviewFlow checkByName = reviewFlowService.getByTitle(reviewFlow.getTitle());
|
||||||
// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
|
// if (checkByName != null && !Objects.equals(checkByName.getId(), reviewFlow.getId())) {
|
||||||
// return fail("该名称已存在");
|
// return fail("该名称已存在");
|
||||||
@@ -99,6 +107,7 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("批量添加审核流")
|
@ApiOperation("批量添加审核流")
|
||||||
@PostMapping("/batch")
|
@PostMapping("/batch")
|
||||||
public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) {
|
public ApiResult<?> saveBatch(@RequestBody List<ReviewFlow> list) {
|
||||||
|
validateLevels(list);
|
||||||
if (reviewFlowService.saveBatch(list)) {
|
if (reviewFlowService.saveBatch(list)) {
|
||||||
return success("添加成功");
|
return success("添加成功");
|
||||||
}
|
}
|
||||||
@@ -108,12 +117,142 @@ public class ReviewFlowController extends BaseController {
|
|||||||
@ApiOperation("批量修改审核流")
|
@ApiOperation("批量修改审核流")
|
||||||
@PutMapping("/batch")
|
@PutMapping("/batch")
|
||||||
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) {
|
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewFlow> batchParam) {
|
||||||
|
validateBatchUpdate(batchParam);
|
||||||
if (batchParam.update(reviewFlowService, "id")) {
|
if (batchParam.update(reviewFlowService, "id")) {
|
||||||
return success("修改成功");
|
return success("修改成功");
|
||||||
}
|
}
|
||||||
return fail("修改失败");
|
return fail("修改失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验"保存后这条审批链的层级从 1 连续升序"(spec #58 行为约定)。
|
||||||
|
*
|
||||||
|
* <p>管理端是"先删旧节点、再逐级新增",因此单条保存时必须把库里同 title 的已有节点一起看,
|
||||||
|
* 否则逐级保存会被误判为缺级。
|
||||||
|
*/
|
||||||
|
private void validateLevels(List<ReviewFlow> incoming) {
|
||||||
|
if (incoming == null || incoming.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> proposed = new ArrayList<>();
|
||||||
|
Set<String> titles = new LinkedHashSet<>();
|
||||||
|
Set<Integer> 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<ReviewFlow> 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<ReviewFlow> batchParam) {
|
||||||
|
if (batchParam == null || batchParam.getData() == null
|
||||||
|
|| batchParam.getIds() == null || batchParam.getIds().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Integer> ids = new ArrayList<>();
|
||||||
|
for (Serializable id : batchParam.getIds()) {
|
||||||
|
if (id instanceof Number) {
|
||||||
|
ids.add(((Number) id).intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> existingRows = reviewFlowService.listByIds(ids);
|
||||||
|
if (existingRows == null || existingRows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ReviewFlow> mergedRows = new ArrayList<>();
|
||||||
|
List<String> oldTitles = new ArrayList<>();
|
||||||
|
for (ReviewFlow existing : existingRows) {
|
||||||
|
if (existing == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedRows.add(merge(existing, batchParam.getData()));
|
||||||
|
oldTitles.add(existing.getTitle());
|
||||||
|
}
|
||||||
|
validateLevels(mergedRows);
|
||||||
|
Set<String> 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<Integer> excludedIds) {
|
||||||
|
List<ReviewFlow> remaining = new ArrayList<>();
|
||||||
|
List<ReviewFlow> 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("批量删除审核流")
|
@ApiOperation("批量删除审核流")
|
||||||
@DeleteMapping("/batch")
|
@DeleteMapping("/batch")
|
||||||
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
|
public ApiResult<?> removeBatch(@RequestBody List<Integer> ids) {
|
||||||
|
|||||||
@@ -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 连续升序)。
|
||||||
|
*
|
||||||
|
* <p>一条审批链的层级必须恰好是 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<ReviewFlow> flows) {
|
||||||
|
if (flows == null || flows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, List<Integer>> levelsByTitle = new LinkedHashMap<>();
|
||||||
|
for (ReviewFlow flow : flows) {
|
||||||
|
if (flow == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
levelsByTitle.computeIfAbsent(titleOf(flow), key -> new ArrayList<>()).add(flow.getLevel());
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, List<Integer>> entry : levelsByTitle.entrySet()) {
|
||||||
|
validateOne(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateOne(String title, List<Integer> levels) {
|
||||||
|
for (Integer level : levels) {
|
||||||
|
if (level == null) {
|
||||||
|
throw new BusinessException("审批链「" + displayTitle(title) + "」存在未填写层级的节点,层级必须从 1 连续升序");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Integer> 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<Integer> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.gxwebsoft.gxmu.service.impl;
|
package com.gxwebsoft.gxmu.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||||
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
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.ReviewListService;
|
||||||
import com.gxwebsoft.gxmu.service.ReviewerResolver;
|
import com.gxwebsoft.gxmu.service.ReviewerResolver;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -68,7 +70,12 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
|
|||||||
saveFirstReviewNode(module, pk, firstOne);
|
saveFirstReviewNode(module, pk, firstOne);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回后重走审批链:先清掉同一申报记录下的旧节点,再生成从第一级开始的新节点,
|
||||||
|
* 保证任一时刻只有一组活跃节点(spec #58 行为约定)。判定"上一条是驳回"必须在删除前完成。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void restartReviewWhenLastRejected(String module, Integer pk) {
|
public void restartReviewWhenLastRejected(String module, Integer pk) {
|
||||||
if (module == null || pk == null) {
|
if (module == null || pk == null) {
|
||||||
return;
|
return;
|
||||||
@@ -94,6 +101,9 @@ public class ReviewFlowStarterServiceImpl implements ReviewFlowStarterService {
|
|||||||
if (firstOne == null) {
|
if (firstOne == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
reviewListService.remove(new LambdaQueryWrapper<ReviewList>()
|
||||||
|
.eq(ReviewList::getModule, module)
|
||||||
|
.eq(ReviewList::getPk, pk));
|
||||||
saveFirstReviewNode(module, pk, firstOne);
|
saveFirstReviewNode(module, pk, firstOne);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>管理端"先删旧节点、再逐级新增",所以单条保存必须结合库里同 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 连续升序)。
|
||||||
|
*
|
||||||
|
* <p>层级是审批顺序的唯一依据:缺一级会让申报卡在半路,重复一级会让两个人同时看到同一条待办。
|
||||||
|
* 这里钉住"保存前就能拒绝"的行为,不依赖数据库。
|
||||||
|
*/
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-26
@@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批流发起测试(mock 掉审批链配置、待办与申报记录读取,不启动 Spring 容器、不连数据库)。
|
* 审批流发起与驳回重走(spec #58)。
|
||||||
*
|
*
|
||||||
* <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法。断言「写进待办的人是谁」
|
* <p>接缝:{@link ReviewFlowStarterServiceImpl} 的公开方法,不启动 Spring 容器、不连数据库。
|
||||||
* 与「解析不出来时一个节点都不写」。</p>
|
* 断言「写进待办的人是谁」、驳回时「先清旧节点再建第一级」、以及解析不出来时「一个节点都不写」。</p>
|
||||||
*
|
*
|
||||||
* @author Codex
|
* @author Codex
|
||||||
* @since 2026-11-10
|
* @since 2026-11-10
|
||||||
@@ -49,7 +49,7 @@ import static org.mockito.Mockito.when;
|
|||||||
class ReviewFlowStarterServiceImplTest {
|
class ReviewFlowStarterServiceImplTest {
|
||||||
|
|
||||||
private static final String MODULE = "gxmu_sjqn";
|
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 COLLEGE_ID = 100;
|
||||||
private static final Integer DECLARE_USER_ID = 12;
|
private static final Integer DECLARE_USER_ID = 12;
|
||||||
|
|
||||||
@@ -75,29 +75,31 @@ class ReviewFlowStarterServiceImplTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
ReviewFlowConfig config = new ReviewFlowConfig();
|
ReviewFlowConfig config = new ReviewFlowConfig();
|
||||||
config.setModule(MODULE);
|
config.setModule(MODULE);
|
||||||
config.setFlowId(11);
|
config.setFlowId(3);
|
||||||
when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config);
|
when(reviewFlowConfigService.getByModule(MODULE)).thenReturn(config);
|
||||||
|
|
||||||
ReviewFlow root = new ReviewFlow();
|
ReviewFlow root = flow("十佳青年", null, 99);
|
||||||
root.setId(11);
|
root.setId(3);
|
||||||
root.setTitle("十佳青年");
|
when(reviewFlowService.getById(3)).thenReturn(root);
|
||||||
when(reviewFlowService.getById(11)).thenReturn(root);
|
when(reviewFlowService.listByTitle("十佳青年"))
|
||||||
|
.thenReturn(Collections.singletonList(flow("十佳青年", 1, 7)));
|
||||||
ReviewFlow first = new ReviewFlow();
|
|
||||||
first.setId(11);
|
|
||||||
first.setTitle("十佳青年");
|
|
||||||
first.setLevel(1);
|
|
||||||
first.setReviewerRule("USER:42");
|
|
||||||
when(reviewFlowService.listByTitle("十佳青年")).thenReturn(Collections.singletonList(first));
|
|
||||||
|
|
||||||
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
|
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.emptyList());
|
||||||
when(declareRecordMapper.selectReviewContext(MODULE, PK)).thenReturn(recordContext());
|
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 升序取第一个),且带上模块、主键与顺序号。 */
|
/** 发起:待办的责任人来自解析结果(按 userId 升序取第一个),且带上模块、主键与顺序号。 */
|
||||||
@Test
|
@Test
|
||||||
void startReview_writesResolvedReviewerAsFirstNode() {
|
void startReview_writesResolvedReviewerAsFirstNode() {
|
||||||
|
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Arrays.asList(42, 43));
|
||||||
|
|
||||||
reviewFlowStarterService.startReview(MODULE, PK);
|
reviewFlowStarterService.startReview(MODULE, PK);
|
||||||
|
|
||||||
ArgumentCaptor<ReviewList> captor = ArgumentCaptor.forClass(ReviewList.class);
|
ArgumentCaptor<ReviewList> captor = ArgumentCaptor.forClass(ReviewList.class);
|
||||||
@@ -134,16 +136,38 @@ class ReviewFlowStarterServiceImplTest {
|
|||||||
verify(reviewListService, never()).save(any());
|
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<ReviewList> 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。 */
|
/** 驳回后重提:重新解析审批人,不复制旧的 review_user_id。 */
|
||||||
@Test
|
@Test
|
||||||
void restartReviewWhenLastRejected_resolvesReviewerAgain() {
|
void restartReviewWhenLastRejected_resolvesReviewerAgain() {
|
||||||
ReviewList rejected = new ReviewList();
|
LocalDateTime rejectedAt = LocalDateTime.of(2026, 11, 1, 10, 0);
|
||||||
rejected.setId(3);
|
when(reviewListService.listByModuleNPK(MODULE, PK))
|
||||||
rejected.setModule(MODULE);
|
.thenReturn(Collections.singletonList(reviewNode(91, 2, rejectedAt)));
|
||||||
rejected.setPk(PK);
|
|
||||||
rejected.setStatus(2);
|
|
||||||
rejected.setCreateTime(LocalDateTime.of(2026, 11, 1, 10, 0));
|
|
||||||
when(reviewListService.listByModuleNPK(MODULE, PK)).thenReturn(Collections.singletonList(rejected));
|
|
||||||
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43));
|
when(reviewerResolver.resolve(any(), any(), any())).thenReturn(Collections.singletonList(43));
|
||||||
|
|
||||||
reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK);
|
reviewFlowStarterService.restartReviewWhenLastRejected(MODULE, PK);
|
||||||
@@ -153,6 +177,59 @@ class ReviewFlowStarterServiceImplTest {
|
|||||||
assertEquals(Integer.valueOf(43), captor.getValue().getUserId());
|
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<String, Object> recordContext() {
|
private Map<String, Object> recordContext() {
|
||||||
Map<String, Object> record = new HashMap<>();
|
Map<String, Object> record = new HashMap<>();
|
||||||
record.put("id", PK);
|
record.put("id", PK);
|
||||||
|
|||||||
Reference in New Issue
Block a user