PortfolioAggregationService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.portfolio.dto.PortfolioDtos.AggregatedTaxonomyNode;
import com.taxonomy.portfolio.dto.PortfolioDtos.ConflictView;
import com.taxonomy.portfolio.dto.PortfolioDtos.MatrixView;
import com.taxonomy.portfolio.dto.PortfolioDtos.PortfolioMetrics;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectPortfolioView;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectSolutionView;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectView;
import com.taxonomy.portfolio.dto.PortfolioDtos.RequirementView;
import com.taxonomy.portfolio.model.PortfolioTypes.ActionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ConflictStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ProductSelectionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import com.taxonomy.portfolio.model.RequirementAnalysisSnapshot;
import com.taxonomy.portfolio.model.RequirementElementMapping;
import com.taxonomy.portfolio.model.RequirementSolutionLink;
import com.taxonomy.portfolio.model.SolutionProductCandidate;
import com.taxonomy.portfolio.repository.RequirementAnalysisSnapshotRepository;
import com.taxonomy.portfolio.repository.RequirementElementMappingRepository;
import com.taxonomy.portfolio.repository.RequirementSolutionLinkRepository;
import com.taxonomy.portfolio.repository.SolutionProductCandidateRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
/** Builds deduplicated project views without losing requirement and snapshot provenance. */
@Service
public class PortfolioAggregationService {
private final ProjectPortfolioService projectService;
private final SolutionPortfolioService solutionService;
private final ProjectConflictService conflictService;
private final RequirementElementMappingRepository elementRepository;
private final RequirementSolutionLinkRepository requirementSolutionRepository;
private final SolutionProductCandidateRepository productCandidateRepository;
private final RequirementAnalysisSnapshotRepository snapshotRepository;
private final int staleAfterDays;
public PortfolioAggregationService(ProjectPortfolioService projectService,
SolutionPortfolioService solutionService,
ProjectConflictService conflictService,
RequirementElementMappingRepository elementRepository,
RequirementSolutionLinkRepository requirementSolutionRepository,
SolutionProductCandidateRepository productCandidateRepository,
RequirementAnalysisSnapshotRepository snapshotRepository,
@Value("${taxonomy.portfolio.snapshot-stale-after-days:30}")
int staleAfterDays) {
this.projectService = projectService;
this.solutionService = solutionService;
this.conflictService = conflictService;
this.elementRepository = elementRepository;
this.requirementSolutionRepository = requirementSolutionRepository;
this.productCandidateRepository = productCandidateRepository;
this.snapshotRepository = snapshotRepository;
this.staleAfterDays = Math.max(1, staleAfterDays);
}
@Transactional(readOnly = true)
public ProjectPortfolioView build(Long projectId,
String username,
WorkspaceContext context) {
ProjectView project = projectService.getProject(projectId, username, context);
List<RequirementView> requirements = projectService.listRequirements(projectId, username, context);
List<RequirementElementMapping> mappings = elementRepository.findCurrentMappingsForProject(projectId);
List<ProjectSolutionView> solutions = solutionService.listProjectSolutions(
projectId, username, context);
List<ConflictView> conflicts = conflictService.list(projectId, username, context);
List<RequirementSolutionLink> solutionLinks = requirementSolutionRepository.findByProjectId(projectId);
List<SolutionProductCandidate> productCandidates = productCandidateRepository.findByProjectId(projectId);
List<AggregatedTaxonomyNode> taxonomyNodes = aggregateTaxonomy(mappings);
MatrixView requirementTaxonomy = requirementTaxonomyMatrix(requirements, mappings);
MatrixView requirementSolution = requirementSolutionMatrix(requirements, solutions, solutionLinks);
MatrixView solutionProduct = solutionProductMatrix(solutions, productCandidates);
PortfolioMetrics metrics = metrics(
requirements, solutions, conflicts, solutionLinks, productCandidates);
return new ProjectPortfolioView(
project,
metrics,
requirements,
taxonomyNodes,
solutions,
conflicts,
requirementTaxonomy,
requirementSolution,
solutionProduct);
}
private List<AggregatedTaxonomyNode> aggregateTaxonomy(
List<RequirementElementMapping> mappings) {
Map<String, List<RequirementElementMapping>> byNode = mappings.stream()
.collect(Collectors.groupingBy(
RequirementElementMapping::getNodeCode,
LinkedHashMap::new,
Collectors.toList()));
return byNode.entrySet().stream()
.map(entry -> {
List<RequirementElementMapping> nodeMappings = entry.getValue();
RequirementElementMapping first = nodeMappings.get(0);
Set<String> requirementKeys = nodeMappings.stream()
.map(mapping -> mapping.getSnapshot().getRequirement().getRequirementKey())
.collect(Collectors.toCollection(TreeSet::new));
Set<String> snapshotIds = nodeMappings.stream()
.map(mapping -> mapping.getSnapshot().getId())
.collect(Collectors.toCollection(TreeSet::new));
Map<ActionStatus, Integer> actions = new EnumMap<>(ActionStatus.class);
for (RequirementElementMapping mapping : nodeMappings) {
actions.merge(mapping.getActionStatus(), 1, Integer::sum);
}
double average = nodeMappings.stream()
.mapToDouble(RequirementElementMapping::getRelevance)
.average().orElse(0.0);
int maximum = nodeMappings.stream()
.mapToInt(RequirementElementMapping::getDirectScore)
.max().orElse(0);
return new AggregatedTaxonomyNode(
entry.getKey(),
first.getNodeTitle(),
first.getTaxonomyRoot(),
requirementKeys.size(),
average,
maximum,
List.copyOf(requirementKeys),
List.copyOf(snapshotIds),
Map.copyOf(actions));
})
.sorted(Comparator.comparing(AggregatedTaxonomyNode::taxonomyRoot)
.thenComparing(AggregatedTaxonomyNode::nodeCode))
.toList();
}
private PortfolioMetrics metrics(List<RequirementView> requirements,
List<ProjectSolutionView> solutions,
List<ConflictView> conflicts,
List<RequirementSolutionLink> solutionLinks,
List<SolutionProductCandidate> productCandidates) {
int analyzed = (int) requirements.stream()
.filter(requirement -> requirement.currentAnalysisSnapshotId() != null)
.count();
int confirmedRequirements = (int) requirements.stream()
.filter(requirement -> requirement.reviewStatus() == ReviewStatus.CONFIRMED)
.count();
Set<Long> requirementsWithConfirmedSolution = solutionLinks.stream()
.filter(link -> link.getReviewStatus() == ReviewStatus.CONFIRMED)
.map(link -> link.getRequirement().getId())
.collect(Collectors.toSet());
int withoutConfirmedSolution = (int) requirements.stream()
.filter(requirement -> !requirementsWithConfirmedSolution.contains(requirement.id()))
.count();
Map<ActionStatus, Integer> solutionsByAction = new EnumMap<>(ActionStatus.class);
for (ProjectSolutionView solution : solutions) {
solutionsByAction.merge(solution.actionStatus(), 1, Integer::sum);
}
int selectedProducts = (int) productCandidates.stream()
.filter(candidate -> candidate.getSelectionStatus() == ProductSelectionStatus.SELECTED)
.count();
int openConflicts = (int) conflicts.stream()
.filter(conflict -> conflict.status() != ConflictStatus.REJECTED
&& conflict.status() != ConflictStatus.RESOLVED)
.count();
int staleSnapshots = countStaleSnapshots(requirements);
return new PortfolioMetrics(
requirements.size(),
analyzed,
confirmedRequirements,
withoutConfirmedSolution,
solutions.size(),
Map.copyOf(solutionsByAction),
productCandidates.size(),
selectedProducts,
openConflicts,
staleSnapshots);
}
private int countStaleSnapshots(List<RequirementView> requirements) {
Instant threshold = Instant.now().minus(Duration.ofDays(staleAfterDays));
int stale = 0;
for (RequirementView requirement : requirements) {
if (requirement.currentAnalysisSnapshotId() == null) continue;
RequirementAnalysisSnapshot snapshot = snapshotRepository
.findById(requirement.currentAnalysisSnapshotId()).orElse(null);
if (snapshot == null || snapshot.getCreatedAt().isBefore(threshold)
|| !Objects.equals(snapshot.getRequirementVersion().getId(), requirement.currentVersionId())) {
stale++;
}
}
return stale;
}
private MatrixView requirementTaxonomyMatrix(List<RequirementView> requirements,
List<RequirementElementMapping> mappings) {
List<String> rows = requirements.stream().map(RequirementView::requirementKey).toList();
List<String> columns = mappings.stream().map(RequirementElementMapping::getNodeCode)
.distinct().sorted().toList();
Map<String, Map<String, Integer>> values = initializeRows(rows);
for (RequirementElementMapping mapping : mappings) {
String requirementKey = mapping.getSnapshot().getRequirement().getRequirementKey();
int value = mapping.getDirectScore() > 0
? mapping.getDirectScore() : (int) Math.round(mapping.getRelevance() * 100.0);
values.computeIfAbsent(requirementKey, ignored -> new LinkedHashMap<>())
.merge(mapping.getNodeCode(), value, Math::max);
}
return immutableMatrix(rows, columns, values);
}
private MatrixView requirementSolutionMatrix(List<RequirementView> requirements,
List<ProjectSolutionView> solutions,
List<RequirementSolutionLink> links) {
List<String> rows = solutions.stream().map(solution -> solution.solution().solutionKey()).toList();
List<String> columns = requirements.stream().map(RequirementView::requirementKey).toList();
Map<String, Map<String, Integer>> values = initializeRows(rows);
for (RequirementSolutionLink link : links) {
values.computeIfAbsent(
link.getProjectSolution().getSolution().getSolutionKey(),
ignored -> new LinkedHashMap<>())
.put(link.getRequirement().getRequirementKey(), link.getCoveragePercent());
}
return immutableMatrix(rows, columns, values);
}
private MatrixView solutionProductMatrix(List<ProjectSolutionView> solutions,
List<SolutionProductCandidate> candidates) {
List<String> rows = solutions.stream().map(solution -> solution.solution().solutionKey()).toList();
List<String> columns = candidates.stream()
.map(candidate -> candidate.getProduct().getProductKey())
.distinct().sorted().toList();
Map<String, Map<String, Integer>> values = initializeRows(rows);
for (SolutionProductCandidate candidate : candidates) {
values.computeIfAbsent(
candidate.getProjectSolution().getSolution().getSolutionKey(),
ignored -> new LinkedHashMap<>())
.put(candidate.getProduct().getProductKey(), candidate.getCoveragePercent());
}
return immutableMatrix(rows, columns, values);
}
private static Map<String, Map<String, Integer>> initializeRows(List<String> rows) {
Map<String, Map<String, Integer>> values = new LinkedHashMap<>();
rows.forEach(row -> values.put(row, new LinkedHashMap<>()));
return values;
}
private static MatrixView immutableMatrix(List<String> rows,
List<String> columns,
Map<String, Map<String, Integer>> values) {
Map<String, Map<String, Integer>> immutableValues = new LinkedHashMap<>();
values.forEach((row, rowValues) -> immutableValues.put(row, Map.copyOf(rowValues)));
return new MatrixView(List.copyOf(rows), List.copyOf(columns), Map.copyOf(immutableValues));
}
}