diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index 1797d94..6836995 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -54,6 +54,7 @@ public class FlowAdminController { private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService; 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") public BasePageResult instancePage( BoaUser user, @RequestParam(value = "status", required = false) Integer status, @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "hung", required = false) Boolean hung, @RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "size", required = false) Integer size) { Long schId = adminGuard.requireAdmin(user, null); - Page p = instanceRepository.pageAdmin(schId, status, keyword, + Page p = instanceRepository.pageAdmin(schId, status, keyword, hung, PageRequest.of(page == null || page < 1 ? 0 : page - 1, size == null || size < 1 || size > 100 ? 10 : size, Sort.by(Sort.Order.desc("id")))); + List pageIds = new java.util.ArrayList<>(); + for (FlwInstance i : p.getContent()) { + pageIds.add(i.getId()); + } + // 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读) + java.util.Set 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 ps = size == null || size < 1 || size > 100 ? 10 : size; return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps, diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java index 0758ab3..5d5452d 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -128,4 +128,12 @@ public class FlwInstance extends BaseEntity { @Version @Column(name = "version", nullable = false) private Integer version; + + /** + * 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。 + * 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充, + * 随实体序列化给前端画红标;业务代码不得读写该字段。 + */ + @javax.persistence.Transient + private Boolean routeHung; } diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java index 1748828..1cc57d6 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java @@ -27,12 +27,23 @@ public interface FlwInstanceRepository extends JpaRepository @Param("status") Integer status, @Param("appKey") String appKey, Pageable pageable); - /** 管理端办件分页(状态/关键字可空) */ + /** + * 管理端办件分页(状态/关键字可空)。 + * hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的最大 attempt + * 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 " + "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 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 均可空=不过滤) */ @Query("select i from FlwInstance i where i.schId = :schId " diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java index 09d8d1b..6f70cce 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwRouteLogRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; import java.util.List; public interface FlwRouteLogRepository extends JpaRepository { @@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository @Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l " + "where l.instanceId = :instanceId and l.nodeId = :nodeId") int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId); + + /** + * 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。 + * 判定口径:某 (instance, node) 的最大 attempt 行 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 findHungInstanceIds(@Param("instanceIds") Collection instanceIds); } diff --git a/src/test/java/com/yunhai/flowcenter/controller/api/FlowAdminInstanceHungTest.java b/src/test/java/com/yunhai/flowcenter/controller/api/FlowAdminInstanceHungTest.java new file mode 100644 index 0000000..b6b3750 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/controller/api/FlowAdminInstanceHungTest.java @@ -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 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 result = controller.instancePage(new BoaUser(), null, null, null, 1, 10); + + List 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()); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index 5788ad4..af69267 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -493,7 +493,8 @@ class FlowDiagramServiceTest { mock(com.yunhai.flowcenter.service.DiagramImageService.class), new ObjectMapper(), 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) {