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)) {