feat(project): 重构项目产品概览统计响应模型支持动态状态看板

- 引入 StatusBoardItemVO 类封装状态看板项,包含状态编码、名称、数量、排序等字段
- 添加 total 字段表示「全部」口径总数,items 字段存储状态看板项列表
- 更新 ProductOverviewSummaryRespVO 和 ProjectOverviewSummaryRespVO 结构
- 移除 ProjectObjectConstants 中的已归档状态常量和默认查询排除状态码集合
- 重写 buildStatusBoardItems 方法实现状态看板项动态构建逻辑
- 调整项目「全部」口径定义,将已取消和已归档状态重新计入统计范围
- 更新相关单元测试验证新字段和统计口径的正确性
- 修改项目分组接口的状态筛选逻辑,使其与新的统计口径保持一致
This commit is contained in:
2026-06-11 13:59:46 +08:00
parent 6807f52ac9
commit 287d0f678a
7 changed files with 223 additions and 74 deletions

View File

@@ -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<String> DEFAULT_QUERY_EXCLUDED_STATUS_CODES = Set.of(STATUS_CANCELLED, STATUS_ARCHIVED);
/**
* 项目自动编码前缀。
*/

View File

@@ -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<String, Long> statusCounts;
@Schema(description = "「全部」口径总数 = items 各状态 count 之和(产品域当前无「全部」视图,口径同项目域:启用状态全集)", example = "12")
private Long total;
@Schema(description = "状态看板项列表,覆盖状态机全部启用状态,按 sort 升序;状态机新增启用状态自动进入清单")
private List<StatusBoardItemVO> 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;
}
}

View File

@@ -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<String, Long> statusCounts;
@Schema(description = "游离项目数:未挂产品且状态属于「全部」口径(状态机启用状态,不含已取消/已归档)")
@Schema(description = "「全部」口径总数 = items 各状态 count 之和2026-06-11 起「全部」= 启用状态全集,作废/归档计入)", example = "48")
private Long total;
@Schema(description = "状态看板项列表,覆盖状态机全部启用状态,按 sort 升序;状态机新增启用状态自动进入清单")
private List<StatusBoardItemVO> 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;
}
}

View File

@@ -383,10 +383,15 @@ public class ProductServiceImpl implements ProductService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE);
ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO();
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows));
Map<String, Long> 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<ProductOverviewSummaryRespVO.StatusBoardItemVO> buildStatusBoardItems(
List<ObjectStatusModelDO> statusModels, Map<String, Long> 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();

View File

@@ -503,16 +503,21 @@ class ProjectServiceImpl implements ProjectService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
List<Map<String, Object>> rows = buildStatusCountRows(scope);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE);
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
Map<String, Long> 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_modelDB 新增状态自动纳入,代码不维护正向清单。
*/
private Set<String> 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<String> defaultStatusCodes = resolveDefaultProjectStatusCodes();
// 状态口径:显式传 statusCode 按单状态过滤,不传时"全部"集合由状态机推导;
// typeCounts 与 projectTotal/projects 同用此状态集2026-06-11 口径变更:随 statusCode 联动)
Collection<String> statusCodes = StringUtils.hasText(reqVO.getStatusCode())
? List.of(reqVO.getStatusCode())
: defaultStatusCodes;
: resolveDefaultProjectStatusCodes();
// 1. 命中项目全量加载(产品几十级、项目百级,内存分组成本可忽略;项目上千后再改 SQL 聚合分页)
List<ProjectDO> matched = selectGroupMatchedProjects(reqVO, statusCodes, projectScope);
@@ -618,12 +622,13 @@ class ProjectServiceImpl implements ProjectService {
List<Long> 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<Long, Map<String, Long>> typeCountsMap = new HashMap<>();
Map<String, Long> orphanTypeCounts = new HashMap<>();
if ((!pageProductIds.isEmpty() || orphanOnPage) && !defaultStatusCodes.isEmpty()) {
if ((!pageProductIds.isEmpty() || orphanOnPage) && !statusCodes.isEmpty()) {
LambdaQueryWrapperX<ProjectDO> typeWrapper = new LambdaQueryWrapperX<ProjectDO>()
.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<ProjectOverviewSummaryRespVO.StatusBoardItemVO> buildStatusBoardItems(
List<ObjectStatusModelDO> statusModels, Map<String, Long> 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)) {

View File

@@ -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<SecurityFrameworkUtils> 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;
}

View File

@@ -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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
MockedStatic<SecurityFrameworkUtils> 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/archived2026-06-11 起「全部」口径不再排除)。 */
private List<ObjectStatusModelDO> projectStatusModels() {
return List.of(
createGroupStatusModel("project", "pending", false),