SolutionPortfolioService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.portfolio.dto.PortfolioDtos.AddProjectSolutionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.CreateSolutionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.LinkRequirementSolutionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectSolutionView;
import com.taxonomy.portfolio.dto.PortfolioDtos.RequirementSolutionLinkView;
import com.taxonomy.portfolio.dto.PortfolioDtos.SolutionView;
import com.taxonomy.portfolio.dto.PortfolioDtos.TaxonomyCoverageView;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateProjectSolutionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateSolutionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpsertTaxonomyCoverageRequest;
import com.taxonomy.portfolio.model.ArchitectureProject;
import com.taxonomy.portfolio.model.PortfolioTypes.ActionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.LifecycleStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.OperatingModel;
import com.taxonomy.portfolio.model.PortfolioTypes.ProjectSolutionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.RequirementSolutionRole;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.SolutionType;
import com.taxonomy.portfolio.model.ProjectRequirement;
import com.taxonomy.portfolio.model.ProjectSolution;
import com.taxonomy.portfolio.model.RequirementAnalysisSnapshot;
import com.taxonomy.portfolio.model.RequirementElementMapping;
import com.taxonomy.portfolio.model.RequirementSolutionLink;
import com.taxonomy.portfolio.model.SolutionDefinition;
import com.taxonomy.portfolio.model.SolutionTaxonomyCoverage;
import com.taxonomy.portfolio.repository.ProjectSolutionRepository;
import com.taxonomy.portfolio.repository.RequirementAnalysisSnapshotRepository;
import com.taxonomy.portfolio.repository.RequirementElementMappingRepository;
import com.taxonomy.portfolio.repository.RequirementSolutionLinkRepository;
import com.taxonomy.portfolio.repository.SolutionDefinitionRepository;
import com.taxonomy.portfolio.repository.SolutionProductCandidateRepository;
import com.taxonomy.portfolio.repository.SolutionTaxonomyCoverageRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/** Reusable solution catalogue plus project-specific solution decisions and coverage. */
@Service
public class SolutionPortfolioService {
private static final Pattern BUSINESS_KEY =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
private final SolutionDefinitionRepository solutionRepository;
private final SolutionTaxonomyCoverageRepository coverageRepository;
private final ProjectSolutionRepository projectSolutionRepository;
private final RequirementSolutionLinkRepository requirementLinkRepository;
private final RequirementElementMappingRepository elementMappingRepository;
private final RequirementAnalysisSnapshotRepository snapshotRepository;
private final SolutionProductCandidateRepository productCandidateRepository;
private final TaxonomyNodeRepository taxonomyNodeRepository;
private final ProjectPortfolioService projectService;
private final ProductCatalogService productService;
private final PortfolioJsonCodec jsonCodec;
public SolutionPortfolioService(SolutionDefinitionRepository solutionRepository,
SolutionTaxonomyCoverageRepository coverageRepository,
ProjectSolutionRepository projectSolutionRepository,
RequirementSolutionLinkRepository requirementLinkRepository,
RequirementElementMappingRepository elementMappingRepository,
RequirementAnalysisSnapshotRepository snapshotRepository,
SolutionProductCandidateRepository productCandidateRepository,
TaxonomyNodeRepository taxonomyNodeRepository,
ProjectPortfolioService projectService,
ProductCatalogService productService,
PortfolioJsonCodec jsonCodec) {
this.solutionRepository = solutionRepository;
this.coverageRepository = coverageRepository;
this.projectSolutionRepository = projectSolutionRepository;
this.requirementLinkRepository = requirementLinkRepository;
this.elementMappingRepository = elementMappingRepository;
this.snapshotRepository = snapshotRepository;
this.productCandidateRepository = productCandidateRepository;
this.taxonomyNodeRepository = taxonomyNodeRepository;
this.projectService = projectService;
this.productService = productService;
this.jsonCodec = jsonCodec;
}
@Transactional
public SolutionView createSolution(CreateSolutionRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("solution request is required");
String scopeKey = PortfolioScope.key(username, context);
String solutionKey = normalizeKey(request.solutionKey(), "solutionKey");
if (solutionRepository.findByScopeKeyAndSolutionKeyIgnoreCase(scopeKey, solutionKey).isPresent()) {
throw PortfolioException.conflict("Solution key already exists in this workspace: " + solutionKey);
}
int maturity = normalizeMaturity(request.maturityLevel());
var costAmount = PortfolioValueValidator.money(request.costAmount(), "costAmount");
String costCurrency = normalizeCurrency(request.costCurrency());
PortfolioValueValidator.requireMoneyPair(
costAmount, costCurrency, "costAmount", "costCurrency");
Integer leadTimeDays = PortfolioValueValidator.nonNegativeDays(
request.leadTimeDays(), "leadTimeDays");
Map<String, String> extensionAttributes = PortfolioValueValidator.extensionAttributes(
request.extensionAttributes() != null ? request.extensionAttributes() : Map.of());
String extensionAttributesJson = ProjectPortfolioService.limited(
jsonCodec.write(extensionAttributes), 4000, "extensionAttributes");
Instant now = Instant.now();
SolutionDefinition solution = new SolutionDefinition(
scopeKey,
PortfolioScope.workspaceId(context),
solutionKey,
ProjectPortfolioService.requireText(request.title(), "title", 240),
ProjectPortfolioService.limited(request.description(), 4000, "description"),
request.solutionType() != null ? request.solutionType() : SolutionType.OTHER,
request.operatingModel() != null ? request.operatingModel() : OperatingModel.UNSPECIFIED,
request.lifecycleStatus() != null ? request.lifecycleStatus() : LifecycleStatus.PLANNED,
maturity,
PortfolioScope.username(username, context),
ProjectPortfolioService.limited(
request.responsibleOrganization(), 240, "responsibleOrganization"),
now);
solution.update(
null,
null,
null,
null,
null,
null,
null,
null,
costAmount,
costCurrency,
ProjectPortfolioService.limited(request.riskNotes(), 2000, "riskNotes"),
leadTimeDays,
extensionAttributesJson,
now);
return toSolutionView(solutionRepository.save(solution));
}
@Transactional(readOnly = true)
public List<SolutionView> listSolutions(String username, WorkspaceContext context) {
return solutionRepository.findByScopeKeyOrderByTitleAsc(PortfolioScope.key(username, context))
.stream().map(this::toSolutionView).toList();
}
@Transactional(readOnly = true)
public SolutionView getSolution(Long solutionId, String username, WorkspaceContext context) {
return toSolutionView(requireSolution(solutionId, username, context));
}
@Transactional
public SolutionView updateSolution(Long solutionId,
UpdateSolutionRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("solution update is required");
SolutionDefinition solution = requireSolution(solutionId, username, context);
var requestedAmount = request.costAmount() != null
? PortfolioValueValidator.money(request.costAmount(), "costAmount") : null;
String requestedCurrency = request.costCurrency() != null
? normalizeCurrency(request.costCurrency()) : null;
var effectiveAmount = request.costAmount() != null
? requestedAmount : solution.getCostAmount();
String effectiveCurrency = request.costCurrency() != null
? requestedCurrency : solution.getCostCurrency();
PortfolioValueValidator.requireMoneyPair(
effectiveAmount, effectiveCurrency, "costAmount", "costCurrency");
Integer leadTimeDays = request.leadTimeDays() != null
? PortfolioValueValidator.nonNegativeDays(request.leadTimeDays(), "leadTimeDays") : null;
String extensionAttributesJson = null;
if (request.extensionAttributes() != null) {
extensionAttributesJson = ProjectPortfolioService.limited(
jsonCodec.write(PortfolioValueValidator.extensionAttributes(request.extensionAttributes())),
4000,
"extensionAttributes");
}
solution.update(
request.title() != null
? ProjectPortfolioService.requireText(request.title(), "title", 240) : null,
ProjectPortfolioService.limited(request.description(), 4000, "description"),
request.solutionType(),
request.operatingModel(),
request.lifecycleStatus(),
request.maturityLevel() != null ? normalizeMaturity(request.maturityLevel()) : null,
request.ownerUsername() != null && !request.ownerUsername().isBlank()
? ProjectPortfolioService.requireText(request.ownerUsername(), "ownerUsername", 160) : null,
ProjectPortfolioService.limited(
request.responsibleOrganization(), 240, "responsibleOrganization"),
requestedAmount,
requestedCurrency,
ProjectPortfolioService.limited(request.riskNotes(), 2000, "riskNotes"),
leadTimeDays,
extensionAttributesJson,
Instant.now());
return toSolutionView(solution);
}
@Transactional
public SolutionView upsertTaxonomyCoverage(Long solutionId,
UpsertTaxonomyCoverageRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("taxonomy coverage is required");
SolutionDefinition solution = requireSolution(solutionId, username, context);
String nodeCode = requireNodeCode(request.nodeCode());
int coverage = normalizePercentage(request.coveragePercent(), "coveragePercent");
Instant now = Instant.now();
SolutionTaxonomyCoverage mapping = coverageRepository
.findBySolutionIdAndNodeCode(solutionId, nodeCode)
.orElseGet(() -> new SolutionTaxonomyCoverage(
solution,
nodeCode,
coverage,
ProjectPortfolioService.limited(request.evidence(), 2000, "evidence"),
request.reviewStatus() != null ? request.reviewStatus() : ReviewStatus.PROPOSED,
PortfolioScope.username(username, context),
now));
if (mapping.getId() != null) {
mapping.update(
coverage,
ProjectPortfolioService.limited(request.evidence(), 2000, "evidence"),
request.reviewStatus(),
PortfolioScope.username(username, context),
now);
}
coverageRepository.save(mapping);
return toSolutionView(solution);
}
@Transactional
public ProjectSolutionView addProjectSolution(Long projectId,
AddProjectSolutionRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("project solution request is required");
ArchitectureProject project = projectService.requireProject(projectId, username, context);
SolutionDefinition solution = requireSolution(request.solutionId(), username, context);
Instant now = Instant.now();
ProjectSolution projectSolution = projectSolutionRepository
.findByProjectIdAndSolutionId(projectId, solution.getId())
.orElseGet(() -> new ProjectSolution(
project,
solution,
request.status() != null ? request.status() : ProjectSolutionStatus.PROPOSED,
request.actionStatus() != null ? request.actionStatus() : ActionStatus.UNDECIDED,
normalizePriority(request.priority()),
ProjectPortfolioService.limited(request.rationale(), 2000, "rationale"),
PortfolioScope.username(username, context),
now));
if (projectSolution.getId() != null) {
projectSolution.update(
request.status(),
request.actionStatus(),
request.priority() != null ? normalizePriority(request.priority()) : null,
ProjectPortfolioService.limited(request.rationale(), 2000, "rationale"),
now);
}
projectSolutionRepository.save(projectSolution);
return toProjectSolutionView(projectSolution);
}
@Transactional
public ProjectSolutionView updateProjectSolution(Long projectId,
Long projectSolutionId,
UpdateProjectSolutionRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("project solution update is required");
projectService.requireProject(projectId, username, context);
ProjectSolution projectSolution = requireProjectSolution(projectId, projectSolutionId);
projectSolution.update(
request.status(),
request.actionStatus(),
request.priority() != null ? normalizePriority(request.priority()) : null,
ProjectPortfolioService.limited(request.rationale(), 2000, "rationale"),
Instant.now());
return toProjectSolutionView(projectSolution);
}
@Transactional
public ProjectSolutionView linkRequirement(Long projectId,
Long projectSolutionId,
LinkRequirementSolutionRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("requirement solution link is required");
ProjectSolution projectSolution = requireProjectSolutionScoped(
projectId, projectSolutionId, username, context);
ProjectRequirement requirement = projectService.requireRequirement(
projectId, request.requirementId(), username, context);
String snapshotId = request.snapshotId();
if (snapshotId == null || snapshotId.isBlank()) {
snapshotId = requirement.getCurrentAnalysisSnapshotId();
}
final String effectiveSnapshotId = snapshotId;
validateSnapshot(effectiveSnapshotId, projectId, requirement.getId());
int coverage = normalizePercentage(request.coveragePercent(), "coveragePercent");
Instant now = Instant.now();
RequirementSolutionLink link = requirementLinkRepository
.findByProjectSolutionIdAndRequirementId(projectSolutionId, requirement.getId())
.orElseGet(() -> new RequirementSolutionLink(
projectSolution,
requirement,
effectiveSnapshotId,
coverage,
request.role() != null ? request.role() : RequirementSolutionRole.USES,
request.reviewStatus() != null ? request.reviewStatus() : ReviewStatus.PROPOSED,
ProjectPortfolioService.limited(request.evidence(), 2000, "evidence"),
PortfolioScope.username(username, context),
now));
if (link.getId() != null) {
link.update(
effectiveSnapshotId,
coverage,
request.role(),
request.reviewStatus(),
ProjectPortfolioService.limited(request.evidence(), 2000, "evidence"),
PortfolioScope.username(username, context),
now);
}
requirementLinkRepository.save(link);
return toProjectSolutionView(projectSolution);
}
/**
* Deterministically proposes reuse candidates from confirmed solution-to-taxonomy coverage.
* Every generated link remains PROPOSED; no action or selection decision is automated.
*/
@Transactional
public List<ProjectSolutionView> proposeFromCurrentMappings(Long projectId,
String username,
WorkspaceContext context) {
ArchitectureProject project = projectService.requireProject(projectId, username, context);
String scopeKey = PortfolioScope.key(username, context);
List<RequirementElementMapping> mappings = elementMappingRepository
.findCurrentMappingsForProject(projectId);
Map<Long, ProjectSolution> touched = new LinkedHashMap<>();
Instant now = Instant.now();
for (RequirementElementMapping element : mappings) {
for (SolutionTaxonomyCoverage coverage : coverageRepository.findByNodeCode(element.getNodeCode())) {
SolutionDefinition solution = coverage.getSolution();
if (!scopeKey.equals(solution.getScopeKey())
|| coverage.getReviewStatus() != ReviewStatus.CONFIRMED) {
continue;
}
ProjectSolution projectSolution = projectSolutionRepository
.findByProjectIdAndSolutionId(projectId, solution.getId())
.orElseGet(() -> projectSolutionRepository.save(new ProjectSolution(
project,
solution,
ProjectSolutionStatus.PROPOSED,
ActionStatus.UNDECIDED,
Math.max(1, element.getDirectScore()),
"Proposed from confirmed taxonomy coverage; human decision required",
PortfolioScope.username(username, context),
now)));
touched.put(projectSolution.getId(), projectSolution);
ProjectRequirement requirement = element.getSnapshot().getRequirement();
int effectiveCoverage = (int) Math.round(
coverage.getCoveragePercent() * Math.max(0.0, Math.min(1.0, element.getRelevance())));
RequirementSolutionLink link = requirementLinkRepository
.findByProjectSolutionIdAndRequirementId(
projectSolution.getId(), requirement.getId())
.orElseGet(() -> new RequirementSolutionLink(
projectSolution,
requirement,
element.getSnapshot().getId(),
effectiveCoverage,
RequirementSolutionRole.USES,
ReviewStatus.PROPOSED,
"Solution covers " + element.getNodeCode()
+ " at " + coverage.getCoveragePercent()
+ "% and the requirement relevance is "
+ Math.round(element.getRelevance() * 100) + "%",
PortfolioScope.username(username, context),
now));
if (link.getId() == null) {
requirementLinkRepository.save(link);
}
}
}
return touched.values().stream().map(this::toProjectSolutionView).toList();
}
@Transactional(readOnly = true)
public List<ProjectSolutionView> listProjectSolutions(Long projectId,
String username,
WorkspaceContext context) {
projectService.requireProject(projectId, username, context);
return projectSolutionRepository.findByProjectIdOrderByPriorityDescSolutionTitleAsc(projectId)
.stream().map(this::toProjectSolutionView).toList();
}
@Transactional(readOnly = true)
public SolutionDefinition requireSolution(Long solutionId,
String username,
WorkspaceContext context) {
if (solutionId == null) throw PortfolioException.validation("solutionId is required");
return solutionRepository.findByIdAndScopeKey(solutionId, PortfolioScope.key(username, context))
.orElseThrow(() -> PortfolioException.notFound("Solution not found: " + solutionId));
}
public SolutionView toSolutionView(SolutionDefinition solution) {
List<TaxonomyCoverageView> coverage = coverageRepository
.findBySolutionIdOrderByNodeCodeAsc(solution.getId()).stream()
.map(mapping -> new TaxonomyCoverageView(
mapping.getId(),
mapping.getNodeCode(),
mapping.getCoveragePercent(),
mapping.getEvidence(),
mapping.getReviewStatus(),
mapping.getUpdatedBy(),
mapping.getUpdatedAt()))
.toList();
return new SolutionView(
solution.getId(),
solution.getSolutionKey(),
solution.getTitle(),
solution.getDescription(),
solution.getSolutionType(),
solution.getOperatingModel(),
solution.getLifecycleStatus(),
solution.getMaturityLevel(),
solution.getOwnerUsername(),
solution.getResponsibleOrganization(),
solution.getCostAmount(),
solution.getCostCurrency(),
solution.getRiskNotes(),
solution.getLeadTimeDays(),
jsonCodec.readStringMap(solution.getExtensionAttributesJson()),
solution.getCreatedAt(),
solution.getUpdatedAt(),
coverage);
}
public ProjectSolutionView toProjectSolutionView(ProjectSolution projectSolution) {
List<RequirementSolutionLinkView> requirements = requirementLinkRepository
.findByProjectSolutionIdOrderByRequirementRequirementKeyAsc(projectSolution.getId()).stream()
.map(link -> new RequirementSolutionLinkView(
link.getId(),
link.getRequirement().getId(),
link.getRequirement().getRequirementKey(),
link.getSnapshotId(),
link.getCoveragePercent(),
link.getRole(),
link.getReviewStatus(),
link.getEvidence(),
link.getUpdatedBy(),
link.getUpdatedAt()))
.toList();
return new ProjectSolutionView(
projectSolution.getId(),
projectSolution.getProject().getId(),
toSolutionView(projectSolution.getSolution()),
projectSolution.getStatus(),
projectSolution.getActionStatus(),
projectSolution.getPriority(),
projectSolution.getRationale(),
projectSolution.getCreatedBy(),
projectSolution.getCreatedAt(),
projectSolution.getUpdatedAt(),
requirements,
productCandidateRepository
.findByProjectSolutionIdOrderByCoveragePercentDesc(projectSolution.getId()).stream()
.map(productService::toCandidateView)
.toList());
}
private ProjectSolution requireProjectSolutionScoped(Long projectId,
Long projectSolutionId,
String username,
WorkspaceContext context) {
projectService.requireProject(projectId, username, context);
return requireProjectSolution(projectId, projectSolutionId);
}
private ProjectSolution requireProjectSolution(Long projectId, Long projectSolutionId) {
if (projectSolutionId == null) {
throw PortfolioException.validation("projectSolutionId is required");
}
return projectSolutionRepository.findByIdAndProjectId(projectSolutionId, projectId)
.orElseThrow(() -> PortfolioException.notFound(
"Project solution not found: " + projectSolutionId));
}
private void validateSnapshot(String snapshotId, Long projectId, Long requirementId) {
if (snapshotId == null || snapshotId.isBlank()) {
throw PortfolioException.validation(
"A requirement solution link requires an analysis snapshot");
}
RequirementAnalysisSnapshot snapshot = snapshotRepository
.findByIdAndProjectId(snapshotId, projectId)
.orElseThrow(() -> PortfolioException.notFound("Analysis snapshot not found: " + snapshotId));
if (!snapshot.getRequirement().getId().equals(requirementId)) {
throw PortfolioException.validation(
"Snapshot " + snapshotId + " does not belong to requirement " + requirementId);
}
}
private String requireNodeCode(String nodeCode) {
String normalized = ProjectPortfolioService.requireText(nodeCode, "nodeCode", 80)
.toUpperCase(Locale.ROOT);
if (taxonomyNodeRepository.findByCode(normalized).isEmpty()) {
throw PortfolioException.validation("Unknown taxonomy node: " + normalized);
}
return normalized;
}
private static String normalizeKey(String value, String field) {
String normalized = ProjectPortfolioService.requireText(value, field, 64)
.toUpperCase(Locale.ROOT);
if (!BUSINESS_KEY.matcher(normalized).matches()) {
throw PortfolioException.validation(field + " contains unsupported characters");
}
return normalized;
}
private static int normalizeMaturity(Integer value) {
int normalized = value != null ? value : 0;
if (normalized < 0 || normalized > 5) {
throw PortfolioException.validation("maturityLevel must be between 0 and 5");
}
return normalized;
}
private static int normalizePriority(Integer value) {
int normalized = value != null ? value : 50;
if (normalized < 0 || normalized > 100) {
throw PortfolioException.validation("priority must be between 0 and 100");
}
return normalized;
}
private static int normalizePercentage(int value, String field) {
if (value < 0 || value > 100) {
throw PortfolioException.validation(field + " must be between 0 and 100");
}
return value;
}
private static String normalizeCurrency(String value) {
return PortfolioValueValidator.currency(value, "costCurrency");
}
}