diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java index 3c51083..b2539fd 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectObjectConstants.java @@ -35,19 +35,6 @@ public final class ProjectObjectConstants { */ public static final String STATUS_CANCELLED = "cancelled"; - /** - * 已归档项目状态编码。 - */ - public static final String STATUS_ARCHIVED = "archived"; - - /** - * "全部"口径的排除锚点:列表/统计缺省不含 已取消、已归档。 - * 状态全集以 rdms_object_status_model 为权威源运行时推导(DB 新增状态自动进入"全部"口径), - * 代码只锚定排除项,与主线唯一性校验的 STATUS_CANCELLED 同属最小硬编码。 - * 设计来源:docs/superpowers/specs/2026-06-10-项目列表产品分组-design.md 第二节。 - */ - public static final Set DEFAULT_QUERY_EXCLUDED_STATUS_CODES = Set.of(STATUS_CANCELLED, STATUS_ARCHIVED); - /** * 项目自动编码前缀。 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java index ddc2f3c..5017465 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductOverviewSummaryRespVO.java @@ -3,13 +3,39 @@ package com.njcn.rdms.module.project.controller.admin.product.vo.product; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.List; import java.util.Map; @Schema(description = "管理后台 - 产品入口页概览统计 Response VO") @Data public class ProductOverviewSummaryRespVO { - @Schema(description = "产品状态数量,按当前启用的产品状态模型返回") + @Schema(description = "产品状态数量,按当前启用的产品状态模型返回(兼容旧前端的过渡字段,前端改造完成后移除)") private Map statusCounts; + @Schema(description = "「全部」口径总数 = items 各状态 count 之和(产品域当前无「全部」视图,口径同项目域:启用状态全集)", example = "12") + private Long total; + + @Schema(description = "状态看板项列表,覆盖状态机全部启用状态,按 sort 升序;状态机新增启用状态自动进入清单") + private List items; + + @Schema(description = "产品状态看板项") + @Data + public static class StatusBoardItemVO { + + @Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active") + private String statusCode; + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "启用") + private String statusName; + @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Long count; + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Integer sort; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + @Schema(description = "是否计入「全部」(当前口径无排除项,恒为 true)", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean includeInAll; + + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java index 2eafaf6..ed86e75 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectOverviewSummaryRespVO.java @@ -3,16 +3,42 @@ package com.njcn.rdms.module.project.controller.admin.project.vo.project; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.List; import java.util.Map; @Schema(description = "管理后台 - 项目入口页概览统计 Response VO") @Data public class ProjectOverviewSummaryRespVO { - @Schema(description = "项目状态数量,按当前启用的项目状态模型返回") + @Schema(description = "项目状态数量,按当前启用的项目状态模型返回(兼容旧前端的过渡字段,前端改造完成后移除)") private Map statusCounts; - @Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态,不含已取消/已归档)") + @Schema(description = "「全部」口径总数 = items 各状态 count 之和(2026-06-11 起「全部」= 启用状态全集,作废/归档计入)", example = "48") + private Long total; + + @Schema(description = "状态看板项列表,覆盖状态机全部启用状态,按 sort 升序;状态机新增启用状态自动进入清单") + private List items; + + @Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态全集)") private Long orphanCount; + @Schema(description = "项目状态看板项") + @Data + public static class StatusBoardItemVO { + + @Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active") + private String statusCode; + @Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "进行中") + private String statusName; + @Schema(description = "数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Long count; + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "20") + private Integer sort; + @Schema(description = "是否终态", example = "false") + private Boolean terminal; + @Schema(description = "是否计入「全部」(当前口径无排除项,恒为 true)", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean includeInAll; + + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java index 54dca5f..94886f7 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java @@ -383,10 +383,15 @@ public class ProductServiceImpl implements ProductService { Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE); List> rows = buildStatusCountRows(scope); + List statusModels = objectStatusModelMapper + .selectListByObjectType(ProductObjectConstants.OBJECT_TYPE); ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO(); - respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper - .selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows)); + Map statusCounts = buildProductStatusCounts(statusModels, rows); + respVO.setStatusCounts(statusCounts); + respVO.setItems(buildStatusBoardItems(statusModels, statusCounts)); + respVO.setTotal(respVO.getItems().stream() + .mapToLong(ProductOverviewSummaryRespVO.StatusBoardItemVO::getCount).sum()); return respVO; } @@ -756,6 +761,31 @@ public class ProductServiceImpl implements ProductService { return statusCounts; } + /** + * 状态看板项:清单由状态机启用状态运行时推导,过滤/排序口径与 buildProductStatusCounts 一致(sort 升序)。 + * 产品域当前无「全部」视图,口径与项目域保持同构(无排除项),includeInAll 恒为 true(设计来源: + * docs/superpowers/specs/2026-06-11-项目产品列表状态看板动态化-design.md 第二节)。 + */ + private List buildStatusBoardItems( + List statusModels, Map statusCounts) { + return statusModels.stream() + .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0)) + .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode())) + .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ObjectStatusModelDO::getStatusCode)) + .map(statusModel -> { + ProductOverviewSummaryRespVO.StatusBoardItemVO item = new ProductOverviewSummaryRespVO.StatusBoardItemVO(); + item.setStatusCode(statusModel.getStatusCode()); + item.setStatusName(statusModel.getStatusName()); + item.setCount(statusCounts.getOrDefault(statusModel.getStatusCode(), 0L)); + item.setSort(statusModel.getSort()); + item.setTerminal(statusModel.getTerminalFlag()); + item.setIncludeInAll(true); + return item; + }) + .toList(); + } + private void writeProductStatusLog(ProductDO product, String actionType, String fromStatus, String toStatus, String reason) { ProductStatusLogDO statusLog = new ProductStatusLogDO(); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java index 317a597..c6de139 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -503,16 +503,21 @@ class ProjectServiceImpl implements ProjectService { Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE); List> rows = buildStatusCountRows(scope); + List statusModels = objectStatusModelMapper + .selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE); ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO(); - respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper - .selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows)); + Map statusCounts = buildProjectStatusCounts(statusModels, rows); + respVO.setStatusCounts(statusCounts); + respVO.setItems(buildStatusBoardItems(statusModels, statusCounts)); + respVO.setTotal(respVO.getItems().stream() + .mapToLong(ProjectOverviewSummaryRespVO.StatusBoardItemVO::getCount).sum()); respVO.setOrphanCount(countOrphanProjects(scope)); return respVO; } /** - * 游离项目计数:未挂产品 + "全部"口径(与项目分组列表对齐)。 + * 游离项目计数:未挂产品 + "全部"口径(启用状态全集,与项目分组列表对齐)。 */ private Long countOrphanProjects(ObjectDataScope scope) { if (scope.getState() == ObjectDataScope.State.EMPTY) { @@ -530,14 +535,13 @@ class ProjectServiceImpl implements ProjectService { } /** - * 项目"全部"口径状态集:状态机启用状态全集 - {已取消, 已归档}。 + * 项目"全部"口径状态集:状态机启用状态全集(2026-06-11 口径变更:作废/归档同样计入,无排除项)。 * 权威源 rdms_object_status_model,DB 新增状态自动纳入,代码不维护正向清单。 */ private Set resolveDefaultProjectStatusCodes() { return objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE).stream() .map(ObjectStatusModelDO::getStatusCode) .filter(StringUtils::hasText) - .filter(code -> !ProjectObjectConstants.DEFAULT_QUERY_EXCLUDED_STATUS_CODES.contains(code)) .collect(Collectors.toSet()); } @@ -559,11 +563,11 @@ class ProjectServiceImpl implements ProjectService { ObjectDataScope projectScope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE); ObjectDataScope productScope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE); - // 状态口径:"全部"集合由状态机推导(typeCounts 恒按该口径,无论是否显式传状态都需要) - Set defaultStatusCodes = resolveDefaultProjectStatusCodes(); + // 状态口径:显式传 statusCode 按单状态过滤,不传时"全部"集合由状态机推导; + // typeCounts 与 projectTotal/projects 同用此状态集(2026-06-11 口径变更:随 statusCode 联动) Collection statusCodes = StringUtils.hasText(reqVO.getStatusCode()) ? List.of(reqVO.getStatusCode()) - : defaultStatusCodes; + : resolveDefaultProjectStatusCodes(); // 1. 命中项目全量加载(产品几十级、项目百级,内存分组成本可忽略;项目上千后再改 SQL 聚合分页) List matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope); @@ -618,12 +622,13 @@ class ProjectServiceImpl implements ProjectService { List pageProductIds = orderedProductIds.subList(fromIndex, Math.min(toIndex, orderedProductIds.size())); boolean orphanOnPage = hasOrphanGroup && toIndex == totalGroups; - // 8. typeCounts(恒按"全部"口径)与 hasBaseline(非已取消主线):产品级属性,不随筛选与数据权限变化 + // 8. typeCounts(与 projectTotal 同口径,随 statusCode 联动;keyword/projectType/数据权限仍不影响) + // 与 hasBaseline(恒按"全部"口径的非已取消主线,不随筛选变化) Map> typeCountsMap = new HashMap<>(); Map orphanTypeCounts = new HashMap<>(); - if ((!pageProductIds.isEmpty() || orphanOnPage) && !defaultStatusCodes.isEmpty()) { + if ((!pageProductIds.isEmpty() || orphanOnPage) && !statusCodes.isEmpty()) { LambdaQueryWrapperX typeWrapper = new LambdaQueryWrapperX() - .in(ProjectDO::getStatusCode, defaultStatusCodes); + .in(ProjectDO::getStatusCode, statusCodes); boolean withOrphan = orphanOnPage; typeWrapper.and(w -> { if (!pageProductIds.isEmpty()) { @@ -1554,6 +1559,31 @@ class ProjectServiceImpl implements ProjectService { return statusCounts; } + /** + * 状态看板项:清单由状态机启用状态运行时推导,过滤/排序口径与 buildProjectStatusCounts 一致(sort 升序)。 + * 「全部」口径无排除项(作废/归档同样计入),includeInAll 恒为 true(设计来源: + * docs/superpowers/specs/2026-06-11-项目产品列表状态看板动态化-design.md 第二节)。 + */ + private List buildStatusBoardItems( + List statusModels, Map statusCounts) { + return statusModels.stream() + .filter(statusModel -> Objects.equals(statusModel.getStatus(), 0)) + .filter(statusModel -> StringUtils.hasText(statusModel.getStatusCode())) + .sorted(Comparator.comparing(ObjectStatusModelDO::getSort, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ObjectStatusModelDO::getStatusCode)) + .map(statusModel -> { + ProjectOverviewSummaryRespVO.StatusBoardItemVO item = new ProjectOverviewSummaryRespVO.StatusBoardItemVO(); + item.setStatusCode(statusModel.getStatusCode()); + item.setStatusName(statusModel.getStatusName()); + item.setCount(statusCounts.getOrDefault(statusModel.getStatusCode(), 0L)); + item.setSort(statusModel.getSort()); + item.setTerminal(statusModel.getTerminalFlag()); + item.setIncludeInAll(true); + return item; + }) + .toList(); + } + private void validateDeleteConfirmText(String confirmText) { String normalizedConfirmText = normalizeNullableText(confirmText); if (!Objects.equals(ProjectObjectConstants.DELETE_CONFIRM_TEXT, normalizedConfirmText)) { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java index 5b081b1..140d87b 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java @@ -229,24 +229,38 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { @Test void getProductOverviewSummary_shouldReturnFixedStatusCountsAndIgnoreUnknownStatus() { - when(objectStatusModelMapper.selectListByObjectType("product")).thenReturn(List.of( - createStatusModel("active", 10, 0), - createStatusModel("paused", 20, 0), - createStatusModel("abandoned", 30, 0), - createStatusModel("disabled", 40, 1) - )); - when(productMapper.selectStatusCountList()).thenReturn(List.of( - Map.of("statusCode", "active", "countValue", 5L), - Map.of("statusCode", "abandoned", "countValue", 1L), - Map.of("statusCode", "unknown", "countValue", 99L) - )); + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L); + when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all()); + when(objectStatusModelMapper.selectListByObjectType("product")).thenReturn(List.of( + createBoardStatusModel("active", "启用", 10, 0, false), + createBoardStatusModel("paused", "暂停", 20, 0, false), + createBoardStatusModel("abandoned", "废弃", 30, 0, true), + createBoardStatusModel("disabled", "已禁用", 40, 1, false) + )); + when(productMapper.selectStatusCountList()).thenReturn(List.of( + Map.of("statusCode", "active", "countValue", 5L), + Map.of("statusCode", "abandoned", "countValue", 1L), + Map.of("statusCode", "unknown", "countValue", 99L) + )); - ProductOverviewSummaryRespVO respVO = productService.getProductOverviewSummary(); + ProductOverviewSummaryRespVO respVO = productService.getProductOverviewSummary(); - assertEquals(3, respVO.getStatusCounts().size()); - assertEquals(5L, respVO.getStatusCounts().get("active")); - assertEquals(0L, respVO.getStatusCounts().get("paused")); - assertEquals(1L, respVO.getStatusCounts().get("abandoned")); + // 旧字段兼容:仅启用状态进 statusCounts,未知状态忽略 + assertEquals(3, respVO.getStatusCounts().size()); + assertEquals(5L, respVO.getStatusCounts().get("active")); + assertEquals(0L, respVO.getStatusCounts().get("paused")); + assertEquals(1L, respVO.getStatusCounts().get("abandoned")); + // 新状态看板:仅启用状态、按 sort 升序、includeInAll 恒 true + assertEquals(3, respVO.getItems().size()); + assertEquals("active", respVO.getItems().get(0).getStatusCode()); + assertEquals("启用", respVO.getItems().get(0).getStatusName()); + assertEquals(5L, respVO.getItems().get(0).getCount()); + assertEquals(Boolean.TRUE, respVO.getItems().get(0).getIncludeInAll()); + assertEquals("abandoned", respVO.getItems().get(2).getStatusCode()); + assertEquals(Boolean.TRUE, respVO.getItems().get(2).getTerminal()); + assertEquals(6L, respVO.getTotal()); // 5+0+1 + } } @Test @@ -658,12 +672,16 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { return statusModel; } - private ObjectStatusModelDO createStatusModel(String statusCode, Integer sort, Integer status) { + /** 状态看板测试用状态机行:带展示名 / 终态标识。 */ + private ObjectStatusModelDO createBoardStatusModel(String statusCode, String statusName, + int sort, int status, boolean terminalFlag) { ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); statusModel.setObjectType("product"); statusModel.setStatusCode(statusCode); + statusModel.setStatusName(statusName); statusModel.setSort(sort); statusModel.setStatus(status); + statusModel.setTerminalFlag(terminalFlag); return statusModel; } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java index f249247..81d2973 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -86,6 +86,10 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Mock private UserObjectRoleMapper userObjectRoleMapper; @Mock + private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; + @Mock + private com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper projectRequirementModuleMapper; + @Mock private ProjectStatusViewService projectStatusViewService; @Mock private ObjectPermissionApi objectPermissionApi; @@ -208,24 +212,43 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { @Test void getProjectOverviewSummary_shouldReturnFixedStatusCountsAndIgnoreUnknownStatus() { - when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(List.of( - createStatusModel("pending", 10, 0), - createStatusModel("active", 20, 0), - createStatusModel("paused", 30, 0), - createStatusModel("disabled", 40, 1) - )); - when(projectMapper.selectStatusCountList()).thenReturn(List.of( - Map.of("statusCode", "pending", "countValue", 2L), - Map.of("statusCode", "active", "countValue", 3L), - Map.of("statusCode", "unknown", "countValue", 99L) - )); + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L); + when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all()); + when(objectStatusModelMapper.selectListByObjectType("project")).thenReturn(List.of( + createBoardStatusModel("pending", "待开始", 10, 0, false), + createBoardStatusModel("active", "进行中", 20, 0, false), + createBoardStatusModel("archived", "已归档", 60, 0, true), + createBoardStatusModel("disabled", "已禁用", 70, 1, false) + )); + when(projectMapper.selectStatusCountList()).thenReturn(List.of( + Map.of("statusCode", "pending", "countValue", 2L), + Map.of("statusCode", "active", "countValue", 3L), + Map.of("statusCode", "archived", "countValue", 4L), + Map.of("statusCode", "unknown", "countValue", 99L) + )); + when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels()); + when(projectMapper.selectCount(any(LambdaQueryWrapperX.class))).thenReturn(1L); - ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary(); + ProjectOverviewSummaryRespVO respVO = projectService.getProjectOverviewSummary(); - assertEquals(3, respVO.getStatusCounts().size()); - assertEquals(2L, respVO.getStatusCounts().get("pending")); - assertEquals(3L, respVO.getStatusCounts().get("active")); - assertEquals(0L, respVO.getStatusCounts().get("paused")); + // 旧字段兼容:仅启用状态进 statusCounts,未知状态忽略 + assertEquals(3, respVO.getStatusCounts().size()); + assertEquals(2L, respVO.getStatusCounts().get("pending")); + assertEquals(3L, respVO.getStatusCounts().get("active")); + assertEquals(4L, respVO.getStatusCounts().get("archived")); + // 新状态看板:仅启用状态、按 sort 升序、归档计入「全部」、includeInAll 恒 true + assertEquals(3, respVO.getItems().size()); + assertEquals("pending", respVO.getItems().get(0).getStatusCode()); + assertEquals("待开始", respVO.getItems().get(0).getStatusName()); + assertEquals(2L, respVO.getItems().get(0).getCount()); + assertEquals(Boolean.FALSE, respVO.getItems().get(0).getTerminal()); + assertEquals(Boolean.TRUE, respVO.getItems().get(0).getIncludeInAll()); + assertEquals("archived", respVO.getItems().get(2).getStatusCode()); + assertEquals(Boolean.TRUE, respVO.getItems().get(2).getTerminal()); + assertEquals(9L, respVO.getTotal()); // 2+3+4,归档计入「全部」总数 + assertEquals(1L, respVO.getOrphanCount()); + } } @Test @@ -563,7 +586,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { ProjectDO p3 = createGroupProject(103L, 11L, "contract", LocalDateTime.of(2026, 6, 2, 10, 0)); ProjectDO p4 = createGroupProject(104L, 12L, "tech_support", LocalDateTime.of(2026, 6, 1, 10, 0)); ProjectDO p5 = createGroupProject(105L, null, "contract", LocalDateTime.of(2026, 6, 1, 10, 0)); - // selectList 调用次序与实现一致:① 命中项目 ② typeCounts(四状态) ③ hasBaseline(主线) + // selectList 调用次序与实现一致:① 命中项目 ② typeCounts(不传 statusCode 时按"全部"口径) ③ hasBaseline(主线) when(projectMapper.selectList(any(LambdaQueryWrapperX.class))) .thenReturn(List.of(p1, p2, p3, p4, p5), List.of(p1, p2, p3, p4, p5), @@ -633,7 +656,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L); when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all()); when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all()); - when(objectStatusModelMapper.selectListByObjectTypeEnabled("project")).thenReturn(projectStatusModels()); + // 显式传 statusCode 时不再推导项目"全部"状态集,仅 stub 产品状态机 when(objectStatusModelMapper.selectListByObjectTypeEnabled("product")).thenReturn(productStatusModels()); ProjectDO p1 = createGroupProject(101L, 11L, "contract", LocalDateTime.of(2026, 6, 1, 10, 0)); @@ -649,6 +672,11 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { assertEquals(1L, respVO.getTotal()); // 仅产品 11;产品 12 不补占位;游离无命中不返回 assertEquals(11L, respVO.getList().get(0).getProductId()); + assertEquals(Map.of("contract", 1L), respVO.getList().get(0).getTypeCounts()); + + // typeCounts 与 projectTotal 同口径:传 statusCode 时全程不推导项目"全部"状态集, + // 即 typeCounts 统计不可能回退到"全部"口径 + verify(objectStatusModelMapper, never()).selectListByObjectTypeEnabled("project"); } } @@ -826,15 +854,6 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { return statusModel; } - private ObjectStatusModelDO createStatusModel(String statusCode, Integer sort, Integer status) { - ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); - statusModel.setObjectType("project"); - statusModel.setStatusCode(statusCode); - statusModel.setSort(sort); - statusModel.setStatus(status); - return statusModel; - } - private MockedStatic mockLoginUser(Long loginUserId, String nickname) { MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); @@ -842,6 +861,19 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { return mockedStatic; } + /** 状态看板测试用状态机行:带展示名 / 终态标识。 */ + private ObjectStatusModelDO createBoardStatusModel(String statusCode, String statusName, + int sort, int status, boolean terminalFlag) { + ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); + statusModel.setObjectType("project"); + statusModel.setStatusCode(statusCode); + statusModel.setStatusName(statusName); + statusModel.setSort(sort); + statusModel.setStatus(status); + statusModel.setTerminalFlag(terminalFlag); + return statusModel; + } + /** * 分组接口测试用状态机行:status=0(启用)。"全部"口径与产品存续状态均由状态机推导,测试必须配齐。 */ @@ -854,7 +886,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { return statusModel; } - /** 项目域状态机 stub:启用状态全集(含 cancelled/archived,推导逻辑应将其排除)。 */ + /** 项目域状态机 stub:启用状态全集(含 cancelled/archived;2026-06-11 起「全部」口径不再排除)。 */ private List projectStatusModels() { return List.of( createGroupStatusModel("project", "pending", false),