[audit] P6.1 F11-4 台账路由挂起可观测:pageAdmin 增 hung 过滤 + 行级 routeHung 红标(口径=max attempt 路由行 outlet 仍 NULL,防误报)
This commit is contained in:
@@ -54,6 +54,7 @@ public class FlowAdminController {
|
|||||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||||
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
|
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
|
||||||
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
|
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
|
||||||
|
private final com.yunhai.flowcenter.repository.FlwRouteLogRepository routeLogRepository;
|
||||||
|
|
||||||
// ==================== 流程定义 ====================
|
// ==================== 流程定义 ====================
|
||||||
|
|
||||||
@@ -462,18 +463,34 @@ public class FlowAdminController {
|
|||||||
|
|
||||||
// ==================== 办件查询 ====================
|
// ==================== 办件查询 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端办件分页(F11-4 增 hung 筛选与行级红标):hung=true 只看路由挂起办件
|
||||||
|
* (口径见 {@link com.yunhai.flowcenter.repository.FlwInstanceRepository#pageAdmin});
|
||||||
|
* 每行回填 {@code routeHung}(按页一次 {@code findHungInstanceIds} 查询,非逐行 N+1)。
|
||||||
|
*/
|
||||||
@GetMapping("/instance/page")
|
@GetMapping("/instance/page")
|
||||||
public BasePageResult<FlwInstance> instancePage(
|
public BasePageResult<FlwInstance> instancePage(
|
||||||
BoaUser user,
|
BoaUser user,
|
||||||
@RequestParam(value = "status", required = false) Integer status,
|
@RequestParam(value = "status", required = false) Integer status,
|
||||||
@RequestParam(value = "keyword", required = false) String keyword,
|
@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
|
@RequestParam(value = "hung", required = false) Boolean hung,
|
||||||
@RequestParam(value = "page", required = false) Integer page,
|
@RequestParam(value = "page", required = false) Integer page,
|
||||||
@RequestParam(value = "size", required = false) Integer size) {
|
@RequestParam(value = "size", required = false) Integer size) {
|
||||||
Long schId = adminGuard.requireAdmin(user, null);
|
Long schId = adminGuard.requireAdmin(user, null);
|
||||||
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
|
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword, hung,
|
||||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||||
size == null || size < 1 || size > 100 ? 10 : size,
|
size == null || size < 1 || size > 100 ? 10 : size,
|
||||||
Sort.by(Sort.Order.desc("id"))));
|
Sort.by(Sort.Order.desc("id"))));
|
||||||
|
List<Long> pageIds = new java.util.ArrayList<>();
|
||||||
|
for (FlwInstance i : p.getContent()) {
|
||||||
|
pageIds.add(i.getId());
|
||||||
|
}
|
||||||
|
// 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读)
|
||||||
|
java.util.Set<Long> hungIds = pageIds.isEmpty() ? java.util.Collections.emptySet()
|
||||||
|
: new java.util.HashSet<>(routeLogRepository.findHungInstanceIds(pageIds));
|
||||||
|
for (FlwInstance i : p.getContent()) {
|
||||||
|
i.setRouteHung(hungIds.contains(i.getId()));
|
||||||
|
}
|
||||||
int pi = page == null || page < 1 ? 1 : page;
|
int pi = page == null || page < 1 ? 1 : page;
|
||||||
int ps = size == null || size < 1 || size > 100 ? 10 : size;
|
int ps = size == null || size < 1 || size > 100 ? 10 : size;
|
||||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
|
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
|
||||||
|
|||||||
@@ -128,4 +128,12 @@ public class FlwInstance extends BaseEntity {
|
|||||||
@Version
|
@Version
|
||||||
@Column(name = "version", nullable = false)
|
@Column(name = "version", nullable = false)
|
||||||
private Integer version;
|
private Integer version;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
|
||||||
|
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
|
||||||
|
* 随实体序列化给前端画红标;业务代码不得读写该字段。
|
||||||
|
*/
|
||||||
|
@javax.persistence.Transient
|
||||||
|
private Boolean routeHung;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,23 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
|
|||||||
@Param("status") Integer status, @Param("appKey") String appKey,
|
@Param("status") Integer status, @Param("appKey") String appKey,
|
||||||
Pageable pageable);
|
Pageable pageable);
|
||||||
|
|
||||||
/** 管理端办件分页(状态/关键字可空) */
|
/**
|
||||||
|
* 管理端办件分页(状态/关键字可空)。
|
||||||
|
* hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的<b>最大 attempt</b>
|
||||||
|
* flw_route_log 行 outlet 仍为 NULL(与 {@link com.yunhai.flowcenter.repository.FlwRouteLogRepository#findHungInstanceIds}
|
||||||
|
* 同口径——历史低 attempt 失败行不参与判定,防「重跑已成功仍报挂起」误报);hung=null/false 不过滤。
|
||||||
|
*/
|
||||||
@Query("select i from FlwInstance i where i.schId = :schId "
|
@Query("select i from FlwInstance i where i.schId = :schId "
|
||||||
+ "and (:status is null or i.status = :status) "
|
+ "and (:status is null or i.status = :status) "
|
||||||
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
|
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) "
|
||||||
|
+ "and (:hung is null or :hung = false or (i.status = 10 and exists ("
|
||||||
|
+ "select r.id from FlwRouteLog r where r.instanceId = i.id and r.outlet is null "
|
||||||
|
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
|
||||||
|
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)))) "
|
||||||
|
+ "order by i.id desc")
|
||||||
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
|
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
|
||||||
@Param("keyword") String keyword, Pageable pageable);
|
@Param("keyword") String keyword, @Param("hung") Boolean hung,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
|
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
|
||||||
@Query("select i from FlwInstance i where i.schId = :schId "
|
@Query("select i from FlwInstance i where i.schId = :schId "
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
|
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
|
||||||
@@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long>
|
|||||||
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
|
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
|
||||||
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
|
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
|
||||||
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
|
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。
|
||||||
|
* 判定口径:某 (instance, node) 的<b>最大 attempt</b> 行 outlet 仍为 NULL 才算挂着——
|
||||||
|
* 退回重走/管理端重跑会写更高 attempt 的新行,历史低 attempt 的失败行(outlet NULL)不参与判定,
|
||||||
|
* 防「已重跑成功仍报挂起」的误报。
|
||||||
|
*/
|
||||||
|
@Query("select distinct r.instanceId from FlwRouteLog r where r.instanceId in :instanceIds "
|
||||||
|
+ "and r.outlet is null "
|
||||||
|
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
|
||||||
|
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)")
|
||||||
|
List<Long> findHungInstanceIds(@Param("instanceIds") Collection<Long> instanceIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.yunhai.flowcenter.controller.api;
|
||||||
|
|
||||||
|
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||||
|
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||||
|
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
|
||||||
|
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||||
|
import com.yunhai.platform.utils.model.BasePageResult;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F11-4 台账「路由挂起」可观测单测:hung 筛选透传 + 行级 routeHung 红标三态
|
||||||
|
* (在途+挂起 / 在途未挂 / 已终态)。挂起判定 JPQL 口径(最大 attempt 行 outlet 仍 NULL)
|
||||||
|
* 在 {@link FlwRouteLogRepository#findHungInstanceIds} 与 pageAdmin 的 exists 子查询里,
|
||||||
|
* 语义断言由 160 人工验收(V 过滤)与真库回归兜住;本测锁定控制器编排与红标填充。
|
||||||
|
*/
|
||||||
|
class FlowAdminInstanceHungTest {
|
||||||
|
|
||||||
|
private final FlowAdminGuard adminGuard = mock(FlowAdminGuard.class);
|
||||||
|
private final FlwInstanceRepository instanceRepository = mock(FlwInstanceRepository.class);
|
||||||
|
private final FlwRouteLogRepository routeLogRepository = mock(FlwRouteLogRepository.class);
|
||||||
|
|
||||||
|
private final FlowAdminController controller = new FlowAdminController(
|
||||||
|
adminGuard,
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowDefAdminService.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowMapAdminService.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowCallbackAdminService.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
|
||||||
|
instanceRepository,
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowDiagramService.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
||||||
|
mock(com.fasterxml.jackson.databind.ObjectMapper.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
|
||||||
|
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
|
||||||
|
routeLogRepository);
|
||||||
|
|
||||||
|
private static FlwInstance instance(long id, int status) {
|
||||||
|
FlwInstance i = new FlwInstance();
|
||||||
|
i.setId(id);
|
||||||
|
i.setStatus(status);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hungFilterPassedThroughToRepository() {
|
||||||
|
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||||
|
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), eq(Boolean.TRUE), any()))
|
||||||
|
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10))));
|
||||||
|
when(routeLogRepository.findHungInstanceIds(any())).thenReturn(Arrays.asList());
|
||||||
|
|
||||||
|
controller.instancePage(new BoaUser(), null, null, Boolean.TRUE, 1, 10);
|
||||||
|
|
||||||
|
ArgumentCaptor<Boolean> hung = ArgumentCaptor.forClass(Boolean.class);
|
||||||
|
verify(instanceRepository).pageAdmin(eq(990001L), isNull(), isNull(), hung.capture(), any());
|
||||||
|
assertEquals(Boolean.TRUE, hung.getValue(), "hung=true 必须原样透传到分页查询");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routeHungBadgeThreeStates() {
|
||||||
|
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||||
|
// 三行:1=在途且在挂起集合;2=在途但不在集合;3=已终态(集合必然不含)
|
||||||
|
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10), instance(2, 10), instance(3, 20))));
|
||||||
|
when(routeLogRepository.findHungInstanceIds(any()))
|
||||||
|
.thenReturn(Arrays.asList(1L));
|
||||||
|
|
||||||
|
BasePageResult<FlwInstance> result = controller.instancePage(new BoaUser(), null, null, null, 1, 10);
|
||||||
|
|
||||||
|
List<FlwInstance> rows = result.getContent();
|
||||||
|
assertEquals(3, rows.size());
|
||||||
|
assertTrue(rows.get(0).getRouteHung(), "在途且最新 attempt 路由行无出口 → 红标 true");
|
||||||
|
assertFalse(rows.get(1).getRouteHung(), "在途但无挂起路由行 → false");
|
||||||
|
assertFalse(rows.get(2).getRouteHung(), "已终态办件不参与挂起判定 → false");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyPageSkipsHungLookup() {
|
||||||
|
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||||
|
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(Arrays.asList()));
|
||||||
|
|
||||||
|
controller.instancePage(new BoaUser(), 10, null, null, 1, 10);
|
||||||
|
|
||||||
|
verify(routeLogRepository, never()).findHungInstanceIds(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -493,7 +493,8 @@ class FlowDiagramServiceTest {
|
|||||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
||||||
new ObjectMapper(),
|
new ObjectMapper(),
|
||||||
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
|
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
|
||||||
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class));
|
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
|
||||||
|
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||||
|
|||||||
Reference in New Issue
Block a user