ProductCatalogService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.portfolio.dto.PortfolioDtos.CreateProductRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProductView;
import com.taxonomy.portfolio.dto.PortfolioDtos.SolutionProductCandidateView;
import com.taxonomy.portfolio.dto.PortfolioDtos.TaxonomyCoverageView;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateProductRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpsertProductCandidateRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpsertTaxonomyCoverageRequest;
import com.taxonomy.portfolio.model.PortfolioTypes.OperatingModel;
import com.taxonomy.portfolio.model.PortfolioTypes.ProductSelectionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ProductStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import com.taxonomy.portfolio.model.ProductCatalogEntry;
import com.taxonomy.portfolio.model.ProductTaxonomyCoverage;
import com.taxonomy.portfolio.model.ProjectSolution;
import com.taxonomy.portfolio.model.SolutionProductCandidate;
import com.taxonomy.portfolio.repository.ProductCatalogEntryRepository;
import com.taxonomy.portfolio.repository.ProductTaxonomyCoverageRepository;
import com.taxonomy.portfolio.repository.ProjectSolutionRepository;
import com.taxonomy.portfolio.repository.SolutionProductCandidateRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/** Manually curated, sourced product catalogue and human-reviewed solution candidates. */
@Service
public class ProductCatalogService {
private static final Pattern BUSINESS_KEY =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
private final ProductCatalogEntryRepository productRepository;
private final ProductTaxonomyCoverageRepository coverageRepository;
private final SolutionProductCandidateRepository candidateRepository;
private final ProjectSolutionRepository projectSolutionRepository;
private final TaxonomyNodeRepository taxonomyNodeRepository;
private final ProjectPortfolioService projectService;
public ProductCatalogService(ProductCatalogEntryRepository productRepository,
ProductTaxonomyCoverageRepository coverageRepository,
SolutionProductCandidateRepository candidateRepository,
ProjectSolutionRepository projectSolutionRepository,
TaxonomyNodeRepository taxonomyNodeRepository,
ProjectPortfolioService projectService) {
this.productRepository = productRepository;
this.coverageRepository = coverageRepository;
this.candidateRepository = candidateRepository;
this.projectSolutionRepository = projectSolutionRepository;
this.taxonomyNodeRepository = taxonomyNodeRepository;
this.projectService = projectService;
}
@Transactional
public ProductView createProduct(CreateProductRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("product request is required");
String scopeKey = PortfolioScope.key(username, context);
String productKey = normalizeKey(request.productKey(), "productKey");
if (productRepository.findByScopeKeyAndProductKeyIgnoreCase(scopeKey, productKey).isPresent()) {
throw PortfolioException.conflict("Product key already exists in this workspace: " + productKey);
}
String source = ProjectPortfolioService.requireText(
request.sourceReference(), "sourceReference", 100_000);
Instant now = Instant.now();
var costAmount = PortfolioValueValidator.money(request.costAmount(), "costAmount");
String costCurrency = normalizeCurrency(request.costCurrency());
PortfolioValueValidator.requireMoneyPair(
costAmount, costCurrency, "costAmount", "costCurrency");
Instant verifiedAt = PortfolioValueValidator.verifiedAt(request.verifiedAt(), now, true);
ProductCatalogEntry product = new ProductCatalogEntry(
scopeKey,
PortfolioScope.workspaceId(context),
productKey,
ProjectPortfolioService.requireText(request.manufacturer(), "manufacturer", 240),
ProjectPortfolioService.limited(request.productFamily(), 240, "productFamily"),
ProjectPortfolioService.requireText(request.productName(), "productName", 240),
ProjectPortfolioService.limited(request.editionVersion(), 160, "editionVersion"),
request.productStatus() != null ? request.productStatus() : ProductStatus.CANDIDATE,
request.endOfSupport(),
ProjectPortfolioService.limited(request.licenseModel(), 500, "licenseModel"),
request.operatingModel() != null ? request.operatingModel() : OperatingModel.UNSPECIFIED,
ProjectPortfolioService.limited(request.supportedPlatforms(), 2000, "supportedPlatforms"),
ProjectPortfolioService.limited(request.securityFeatures(), 4000, "securityFeatures"),
ProjectPortfolioService.limited(request.complianceFeatures(), 4000, "complianceFeatures"),
costAmount,
costCurrency,
ProjectPortfolioService.limited(request.costBasis(), 500, "costBasis"),
source,
verifiedAt,
PortfolioScope.username(username, context),
now);
return toProductView(productRepository.save(product));
}
@Transactional(readOnly = true)
public List<ProductView> listProducts(String username, WorkspaceContext context) {
return productRepository
.findByScopeKeyOrderByManufacturerAscProductNameAsc(PortfolioScope.key(username, context))
.stream().map(this::toProductView).toList();
}
@Transactional(readOnly = true)
public ProductView getProduct(Long productId, String username, WorkspaceContext context) {
return toProductView(requireProduct(productId, username, context));
}
@Transactional
public ProductView updateProduct(Long productId,
UpdateProductRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("product update is required");
ProductCatalogEntry product = requireProduct(productId, username, context);
Instant now = Instant.now();
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 : product.getCostAmount();
String effectiveCurrency = request.costCurrency() != null
? requestedCurrency : product.getCostCurrency();
PortfolioValueValidator.requireMoneyPair(
effectiveAmount, effectiveCurrency, "costAmount", "costCurrency");
Instant verifiedAt = request.verifiedAt() != null
? PortfolioValueValidator.verifiedAt(request.verifiedAt(), now, false) : null;
product.update(
request.manufacturer() != null
? ProjectPortfolioService.requireText(request.manufacturer(), "manufacturer", 240) : null,
ProjectPortfolioService.limited(request.productFamily(), 240, "productFamily"),
request.productName() != null
? ProjectPortfolioService.requireText(request.productName(), "productName", 240) : null,
ProjectPortfolioService.limited(request.editionVersion(), 160, "editionVersion"),
request.productStatus(),
request.endOfSupport(),
ProjectPortfolioService.limited(request.licenseModel(), 500, "licenseModel"),
request.operatingModel(),
ProjectPortfolioService.limited(request.supportedPlatforms(), 2000, "supportedPlatforms"),
ProjectPortfolioService.limited(request.securityFeatures(), 4000, "securityFeatures"),
ProjectPortfolioService.limited(request.complianceFeatures(), 4000, "complianceFeatures"),
requestedAmount,
requestedCurrency,
ProjectPortfolioService.limited(request.costBasis(), 500, "costBasis"),
request.sourceReference() != null
? ProjectPortfolioService.requireText(
request.sourceReference(), "sourceReference", 100_000) : null,
verifiedAt,
now);
return toProductView(product);
}
@Transactional
public ProductView upsertTaxonomyCoverage(Long productId,
UpsertTaxonomyCoverageRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("taxonomy coverage is required");
ProductCatalogEntry product = requireProduct(productId, username, context);
String nodeCode = requireNodeCode(request.nodeCode());
int coverage = normalizePercentage(request.coveragePercent(), "coveragePercent");
Instant now = Instant.now();
ProductTaxonomyCoverage mapping = coverageRepository
.findByProductIdAndNodeCode(productId, nodeCode)
.orElseGet(() -> new ProductTaxonomyCoverage(
product,
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 toProductView(product);
}
@Transactional
public SolutionProductCandidateView upsertCandidate(Long projectId,
Long projectSolutionId,
UpsertProductCandidateRequest request,
String username,
WorkspaceContext context) {
if (request == null) throw PortfolioException.validation("product candidate is required");
projectService.requireProject(projectId, username, context);
ProjectSolution projectSolution = projectSolutionRepository
.findByIdAndProjectId(projectSolutionId, projectId)
.orElseThrow(() -> PortfolioException.notFound(
"Project solution not found: " + projectSolutionId));
ProductCatalogEntry product = requireProduct(request.productId(), username, context);
int coverage = normalizePercentage(request.coveragePercent(), "coveragePercent");
double confidence = normalizeConfidence(request.confidence());
ReviewStatus reviewStatus = request.reviewStatus() != null
? request.reviewStatus() : ReviewStatus.PROPOSED;
ProductSelectionStatus selectionStatus = request.selectionStatus() != null
? request.selectionStatus() : ProductSelectionStatus.CANDIDATE;
String exclusions = ProjectPortfolioService.limited(
request.hardExclusions(), 4000, "hardExclusions");
if (selectionStatus == ProductSelectionStatus.SELECTED) {
if (reviewStatus != ReviewStatus.CONFIRMED) {
throw PortfolioException.validation(
"A selected product candidate must have reviewStatus CONFIRMED");
}
if (exclusions != null && !exclusions.isBlank()) {
throw PortfolioException.validation(
"A product with hard exclusions cannot be selected");
}
}
Instant now = Instant.now();
SolutionProductCandidate candidate = candidateRepository
.findByProjectSolutionIdAndProductId(projectSolutionId, product.getId())
.orElseGet(() -> new SolutionProductCandidate(
projectSolution,
product,
coverage,
exclusions,
ProjectPortfolioService.limited(request.strengths(), 4000, "strengths"),
ProjectPortfolioService.limited(request.weaknesses(), 4000, "weaknesses"),
ProjectPortfolioService.limited(request.openEvidence(), 4000, "openEvidence"),
confidence,
reviewStatus,
selectionStatus,
PortfolioScope.username(username, context),
now));
if (candidate.getId() != null) {
candidate.update(
coverage,
exclusions,
ProjectPortfolioService.limited(request.strengths(), 4000, "strengths"),
ProjectPortfolioService.limited(request.weaknesses(), 4000, "weaknesses"),
ProjectPortfolioService.limited(request.openEvidence(), 4000, "openEvidence"),
confidence,
reviewStatus,
selectionStatus,
PortfolioScope.username(username, context),
now);
}
candidateRepository.save(candidate);
return toCandidateView(candidate);
}
@Transactional(readOnly = true)
public List<SolutionProductCandidateView> listCandidates(Long projectId,
Long projectSolutionId,
String username,
WorkspaceContext context) {
projectService.requireProject(projectId, username, context);
projectSolutionRepository.findByIdAndProjectId(projectSolutionId, projectId)
.orElseThrow(() -> PortfolioException.notFound(
"Project solution not found: " + projectSolutionId));
return candidateRepository.findByProjectSolutionIdOrderByCoveragePercentDesc(projectSolutionId)
.stream().map(this::toCandidateView).toList();
}
@Transactional(readOnly = true)
public ProductCatalogEntry requireProduct(Long productId,
String username,
WorkspaceContext context) {
if (productId == null) throw PortfolioException.validation("productId is required");
return productRepository.findByIdAndScopeKey(productId, PortfolioScope.key(username, context))
.orElseThrow(() -> PortfolioException.notFound("Product not found: " + productId));
}
public ProductView toProductView(ProductCatalogEntry product) {
List<TaxonomyCoverageView> coverage = coverageRepository
.findByProductIdOrderByNodeCodeAsc(product.getId()).stream()
.map(mapping -> new TaxonomyCoverageView(
mapping.getId(),
mapping.getNodeCode(),
mapping.getCoveragePercent(),
mapping.getEvidence(),
mapping.getReviewStatus(),
mapping.getUpdatedBy(),
mapping.getUpdatedAt()))
.toList();
return new ProductView(
product.getId(),
product.getProductKey(),
product.getManufacturer(),
product.getProductFamily(),
product.getProductName(),
product.getEditionVersion(),
product.getProductStatus(),
product.getEndOfSupport(),
product.getLicenseModel(),
product.getOperatingModel(),
product.getSupportedPlatforms(),
product.getSecurityFeatures(),
product.getComplianceFeatures(),
product.getCostAmount(),
product.getCostCurrency(),
product.getCostBasis(),
product.getSourceReference(),
product.getVerifiedAt(),
product.getCreatedBy(),
product.getCreatedAt(),
product.getUpdatedAt(),
coverage);
}
public SolutionProductCandidateView toCandidateView(SolutionProductCandidate candidate) {
return new SolutionProductCandidateView(
candidate.getId(),
candidate.getProjectSolution().getId(),
toProductView(candidate.getProduct()),
candidate.getCoveragePercent(),
candidate.getHardExclusions(),
candidate.getStrengths(),
candidate.getWeaknesses(),
candidate.getOpenEvidence(),
candidate.getConfidence(),
candidate.getReviewStatus(),
candidate.getSelectionStatus(),
candidate.getUpdatedBy(),
candidate.getUpdatedAt());
}
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 normalizePercentage(int value, String field) {
if (value < 0 || value > 100) {
throw PortfolioException.validation(field + " must be between 0 and 100");
}
return value;
}
private static double normalizeConfidence(double value) {
if (!Double.isFinite(value) || value < 0.0 || value > 1.0) {
throw PortfolioException.validation("confidence must be between 0.0 and 1.0");
}
return value;
}
private static String normalizeCurrency(String value) {
return PortfolioValueValidator.currency(value, "costCurrency");
}
}