New V1 Products api. it s versioned for the future and has multiple new functions to groups offers into products, a best price response, etc.

This commit is contained in:
2025-12-19 04:29:25 -05:00
parent 12b3d2ae50
commit ed1e08cbfa
4 changed files with 432 additions and 60 deletions

View File

@@ -0,0 +1,148 @@
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repos.ProductOfferRepository;
import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.web.dto.ProductDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
@CrossOrigin
@RestController
@RequestMapping("/api/v1/products")
public class ProductV1Controller {
private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository;
public ProductV1Controller(
ProductRepository productRepository,
ProductOfferRepository productOfferRepository
) {
this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository;
}
/**
* List products (v1 summary contract)
*
* Examples:
* - GET /api/v1/products?platform=AR-15
* - GET /api/v1/products?platform=AR-15&partRoles=complete-upper&partRoles=upper-receiver
* - GET /api/v1/products?platform=ALL
*/
@GetMapping
public List<ProductDto> listProducts(
@RequestParam(name = "platform", required = false) String platform,
@RequestParam(name = "partRoles", required = false) List<String> partRoles
) {
boolean allPlatforms =
(platform == null || platform.isBlank() || platform.equalsIgnoreCase("ALL"));
List<Product> products;
if (partRoles == null || partRoles.isEmpty()) {
products = allPlatforms
? productRepository.findAllWithBrand()
: productRepository.findByPlatformWithBrand(platform);
} else {
List<String> roles = partRoles.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
if (roles.isEmpty()) {
products = allPlatforms
? productRepository.findAllWithBrand()
: productRepository.findByPlatformWithBrand(platform);
} else {
products = allPlatforms
? productRepository.findByPartRoleInWithBrand(roles)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, roles);
}
}
if (products.isEmpty()) return List.of();
List<Integer> productIds = products.stream().map(Product::getId).toList();
List<ProductOffer> offers = productOfferRepository.findByProductIdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = offers.stream()
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
return products.stream()
.map(p -> {
ProductOffer best = pickBestOffer(offersByProductId.get(p.getId()));
return toProductDto(p, best);
})
.toList();
}
/**
* Product details (v1 contract)
* NOTE: accepts String to avoid MethodArgumentTypeMismatch on /undefined etc.
*/
@GetMapping("/{id}")
public ResponseEntity<ProductDto> getProduct(@PathVariable("id") String id) {
Integer productId = parsePositiveInt(id);
if (productId == null) {
return ResponseEntity.badRequest().build();
}
Optional<Product> productOpt = productRepository.findById(productId);
if (productOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
ProductOffer best = pickBestOffer(offers);
return ResponseEntity.ok(toProductDto(productOpt.get(), best));
}
private static Integer parsePositiveInt(String raw) {
try {
int n = Integer.parseInt(raw);
return n > 0 ? n : null;
} catch (Exception e) {
return null;
}
}
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
if (offers == null || offers.isEmpty()) return null;
// MVP: lowest effective price wins
return offers.stream()
.filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null);
}
private ProductDto toProductDto(Product p, ProductOffer bestOffer) {
ProductDto dto = new ProductDto();
dto.setId(String.valueOf(p.getId()));
dto.setName(p.getName());
dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null);
dto.setPlatform(p.getPlatform());
dto.setPartRole(p.getPartRole());
dto.setCategoryKey(p.getRawCategoryKey());
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
dto.setPrice(price);
dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null);
// v1: match what the UI expects today
dto.setImageUrl(p.getMainImageUrl());
return dto;
}
}

View File

@@ -1,3 +1,11 @@
// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted
// This is the primary ETL pipeline that ingests merchant product feeds,
// normalizes them, classifies platform/part-role, and upserts products + offers.
//
// IMPORTANT DESIGN NOTES:
// - This importer is authoritative for what ends up in the DB
// - PlatformResolver is applied HERE so unsupported products never leak downstream
// - UI, APIs, and builder assume DB data is already “clean enough”
package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
@@ -10,7 +18,7 @@ import group.goforward.battlbuilder.repos.BrandRepository;
import group.goforward.battlbuilder.repos.MerchantRepository;
import group.goforward.battlbuilder.repos.ProductOfferRepository;
import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.services.CategoryClassificationService;
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
import group.goforward.battlbuilder.services.MerchantFeedImportService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
@@ -30,17 +38,18 @@ import java.time.OffsetDateTime;
import java.util.*;
/**
* Merchant feed ETL + offer sync.
* MerchantFeedImportServiceImpl
*
* - importMerchantFeed: full ETL (products + offers)
* - syncOffersOnly: only refresh offers/prices/stock from an offers feed
* RESPONSIBILITIES:
* - Read merchant product feeds (CSV/TSV/etc)
* - Normalize product data into Product entities
* - Resolve PLATFORM (AR-15, AR-10, NOT-SUPPORTED)
* - Infer part roles (temporary heuristic)
* - Upsert Product + ProductOffer rows
*
* IMPORTANT:
* Classification (platform + partRole + rawCategoryKey) must run through CategoryClassificationService
* so we respect:
* 1) merchant_category_mappings (admin UI mapping)
* 2) rule-based resolver
* 3) fallback inference
* NON-GOALS:
* - Perfect classification (thats iterative)
* - UI-level filtering (handled later)
*/
@Service
@Transactional
@@ -51,26 +60,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
private final MerchantRepository merchantRepository;
private final BrandRepository brandRepository;
private final ProductRepository productRepository;
// --- Classification ---
// DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED)
private final PlatformResolver platformResolver;
private final ProductOfferRepository productOfferRepository;
private final CategoryClassificationService categoryClassificationService;
public MerchantFeedImportServiceImpl(
MerchantRepository merchantRepository,
BrandRepository brandRepository,
ProductRepository productRepository,
ProductOfferRepository productOfferRepository,
CategoryClassificationService categoryClassificationService
PlatformResolver platformResolver,
ProductOfferRepository productOfferRepository
) {
this.merchantRepository = merchantRepository;
this.brandRepository = brandRepository;
this.productRepository = productRepository;
this.platformResolver = platformResolver;
this.productOfferRepository = productOfferRepository;
this.categoryClassificationService = categoryClassificationService;
}
// ---------------------------------------------------------------------
// Full product + offer import
// ---------------------------------------------------------------------
// =========================================================================
// FULL PRODUCT + OFFER IMPORT
// =========================================================================
//
// This is the main ETL entry point.
// Triggered via:
// POST /api/admin/imports/{merchantId}
//
// Cache eviction ensures builder/API reads see fresh data.
//
@Override
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
@@ -80,26 +98,52 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// Read & parse CSV feed into structured rows
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
int processed = 0;
int notSupported = 0;
// Main ETL loop
for (MerchantFeedRow row : rows) {
// 1) Resolve brand (create if missing)
Brand brand = resolveBrand(row);
// 2) Upsert product + offer
Product p = upsertProduct(merchant, brand, row);
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
processed++;
// Metrics: how much we are filtering out
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
notSupported++;
}
// Periodic progress logging
if (processed % 500 == 0) {
log.info("Import progress merchantId={} processed={}/{} notSupportedSoFar={}",
merchantId, processed, rows.size(), notSupported);
}
}
merchant.setLastFullImportAt(OffsetDateTime.now());
merchantRepository.save(merchant);
log.info("Completed full import for merchantId={} ({} rows processed)", merchantId, rows.size());
log.info("Completed full import for merchantId={} rows={} processed={} notSupported={}",
merchantId, rows.size(), processed, notSupported);
}
// ---------------------------------------------------------------------
// Product upsert
// ---------------------------------------------------------------------
// =========================================================================
// PRODUCT UPSERT
// =========================================================================
//
// Strategy:
// - Match existing products by Brand + MPN (preferred)
// - Fallback to Brand + UPC (temporary)
// - Create new product if no match
//
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
String mpn = trimOrNull(row.manufacturerId());
@@ -126,10 +170,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
brand.getName(), mpn, upc, candidates.get(0).getId());
}
p = candidates.get(0);
// keep brand stable (but ensure it's set)
if (p.getBrand() == null) p.setBrand(brand);
}
// Offers are merchant-specific → always upsert
updateProductFromRow(p, merchant, row, isNew);
Product saved = productRepository.save(p);
@@ -139,13 +182,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return saved;
}
// =========================================================================
// PRODUCT NORMALIZATION + CLASSIFICATION
// =========================================================================
//
// This is the MOST IMPORTANT method in the file.
// If data looks wrong in the UI, 90% of the time the bug is here.
//
private void updateProductFromRow(Product p,
Merchant merchant,
MerchantFeedRow row,
boolean isNew) {
// ---------- NAME ----------
String name = coalesce(
// Prefer productName, fallback to descriptions or SKU
//
String name = coalesce(
trimOrNull(row.productName()),
trimOrNull(row.shortDescription()),
trimOrNull(row.longDescription()),
@@ -155,12 +207,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setName(name);
// ---------- SLUG ----------
// Only generate once (unless missing)
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku()));
if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis();
String slug = baseForSlug
.toLowerCase(Locale.ROOT)
.toLowerCase()
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
@@ -186,26 +239,55 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setMpn(mpn);
p.setUpc(null); // placeholder
// ---------- CLASSIFICATION (rawCategoryKey + platform + partRole) ----------
CategoryClassificationService.Result r = categoryClassificationService.classify(merchant, row);
// ---------- RAW CATEGORY KEY ----------
String rawCategoryKey = buildRawCategoryKey(row);
p.setRawCategoryKey(rawCategoryKey);
// Always persist the rawCategoryKey coming out of classification (consistent keying)
p.setRawCategoryKey(r.rawCategoryKey());
// ---------- PLATFORM RESOLUTION ----------
//
// ORDER OF OPERATIONS:
// 1) Base heuristic (string contains AR-15, AR-10, etc)
// 2) PlatformResolver DB rules (can override to NOT-SUPPORTED)
//
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
String basePlatform = inferPlatform(row);
// Respect platformLocked: if locked and platform already present, keep it.
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked()) || p.getPlatform() == null || p.getPlatform().isBlank()) {
String platform = (r.platform() == null || r.platform().isBlank()) ? "AR-15" : r.platform();
p.setPlatform(platform);
Long mId = merchant.getId() == null ? null : merchant.getId().longValue();
Long bId = (p.getBrand() != null && p.getBrand().getId() != null) ? p.getBrand().getId().longValue() : null;
// DB rules can force NOT-SUPPORTED (or AR-10, etc.)
String resolvedPlatform = platformResolver.resolve(
mId,
bId,
p.getName(),
rawCategoryKey
);
String finalPlatform = resolvedPlatform != null
? resolvedPlatform
: (basePlatform != null ? basePlatform : "AR-15");
p.setPlatform(finalPlatform);
}
// Part role should always be driven by classification (mapping/rules/inference),
// but if something returns null/blank, treat as unknown.
String partRole = (r.partRole() == null) ? "unknown" : r.partRole().trim();
if (partRole.isBlank()) partRole = "unknown";
// ---------- PART ROLE (TEMPORARY) ----------
// This is intentionally weak — PartRoleResolver + mappings improve this later
String partRole = inferPartRole(row);
if (partRole == null || partRole.isBlank()) {
partRole = "UNKNOWN";
} else {
partRole = partRole.trim();
}
p.setPartRole(partRole);
// ---------- IMPORT STATUS ----------
if ("unknown".equalsIgnoreCase(partRole) || "UNKNOWN".equalsIgnoreCase(partRole)) {
// If platform is NOT-SUPPORTED, force PENDING so it's easy to audit.
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
p.setImportStatus(ImportStatus.PENDING_MAPPING);
return;
}
if ("UNKNOWN".equalsIgnoreCase(partRole)) {
p.setImportStatus(ImportStatus.PENDING_MAPPING);
} else {
p.setImportStatus(ImportStatus.MAPPED);
@@ -303,15 +385,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
merchant.setLastOfferSyncAt(OffsetDateTime.now());
merchantRepository.save(merchant);
log.info("Completed offers-only sync for merchantId={} ({} rows processed)",
log.info("Completed offers-only sync for merchantId={} ({} rows processed)",
merchantId, rows.size());
}
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
String avantlinkProductId = trimOrNull(row.get("SKU"));
if (avantlinkProductId == null || avantlinkProductId.isBlank()) {
return;
}
if (avantlinkProductId == null || avantlinkProductId.isBlank()) return;
ProductOffer offer = productOfferRepository
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
@@ -348,7 +428,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
if (lower.contains("false") || lower.contains("no") || lower.contains("0") || lower.contains("out of stock")) {
return Boolean.FALSE;
}
return Boolean.FALSE;
}
@@ -397,9 +476,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
// Try a few common delimiters, but only require the SKU header to be present.
char[] delimiters = new char[]{'\t', ',', ';', '|'};
List<String> requiredHeaders = Collections.singletonList("SKU");
List<String> requiredHeaders = Arrays.asList("SKU");
Exception lastException = null;
@@ -436,10 +514,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl);
if (lastException != null) {
log.debug("Last delimiter detection error:", lastException);
}
return CSVFormat.DEFAULT.builder()
.setDelimiter(',')
.setHeader()
@@ -541,7 +615,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
private String getCsvValue(CSVRecord rec, String header) {
if (rec == null || header == null) return null;
if (!rec.isMapped(header)) return null;
try {
return rec.get(header);
} catch (IllegalArgumentException ex) {
@@ -574,4 +647,83 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
return candidate;
}
private String buildRawCategoryKey(MerchantFeedRow row) {
String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
List<String> parts = new ArrayList<>();
if (dept != null) parts.add(dept);
if (cat != null) parts.add(cat);
if (sub != null) parts.add(sub);
return parts.isEmpty() ? null : String.join(" > ", parts);
}
private String inferPlatform(MerchantFeedRow row) {
// Use *all* category signals. Many feeds put AR-10/AR-15 in SubCategory.
String blob = String.join(" ",
coalesce(trimOrNull(row.department()), ""),
coalesce(trimOrNull(row.category()), ""),
coalesce(trimOrNull(row.subCategory()), "")
).toLowerCase(Locale.ROOT);
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
if (blob.contains("ak-47") || blob.contains("ak47") || blob.contains("ak ")) return "AK-47";
return "AR-15"; // safe default
}
private String inferPartRole(MerchantFeedRow row) {
// Use more than just subCategory; complete uppers were being mislabeled previously.
String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
String name = trimOrNull(row.productName());
String rawCategoryKey = buildRawCategoryKey(row);
String combined = String.join(" ",
coalesce(rawCategoryKey, ""),
coalesce(dept, ""),
coalesce(cat, ""),
coalesce(sub, ""),
coalesce(name, "")
).toLowerCase(Locale.ROOT);
// ---- High priority: complete assemblies ----
if (combined.contains("complete upper") ||
combined.contains("complete uppers") ||
combined.contains("upper receiver assembly") ||
combined.contains("barreled upper")) {
return "complete-upper";
}
if (combined.contains("complete lower") ||
combined.contains("complete lowers") ||
combined.contains("lower receiver assembly")) {
return "complete-lower";
}
// ---- Receivers ----
if (combined.contains("stripped upper")) return "upper-receiver";
if (combined.contains("stripped lower")) return "lower-receiver";
// ---- Common parts ----
if (combined.contains("handguard") || combined.contains("rail")) return "handguard";
if (combined.contains("barrel")) return "barrel";
if (combined.contains("gas block") || combined.contains("gasblock")) return "gas-block";
if (combined.contains("gas tube") || combined.contains("gastube")) return "gas-tube";
if (combined.contains("charging handle")) return "charging-handle";
if (combined.contains("bolt carrier") || combined.contains(" bcg")) return "bcg";
if (combined.contains("magazine") || combined.contains(" mag ")) return "magazine";
if (combined.contains("stock") || combined.contains("buttstock") || combined.contains("brace")) return "stock";
if (combined.contains("pistol grip") || combined.contains(" grip")) return "grip";
if (combined.contains("trigger")) return "trigger";
return "unknown";
}
}

View File

@@ -0,0 +1,53 @@
package group.goforward.battlbuilder.web.dto;
import java.math.BigDecimal;
import java.util.List;
public class ProductDetailsDto {
private String id;
private String name;
private String brand;
private String platform;
private String partRole;
private String categoryKey;
private String imageUrl;
// “Best” offer snapshot (what list uses today)
private BigDecimal price;
private String buyUrl;
// Full offer list for the details page
private List<ProductOfferDto> offers;
public ProductDetailsDto() {}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getPartRole() { return partRole; }
public void setPartRole(String partRole) { this.partRole = partRole; }
public String getCategoryKey() { return categoryKey; }
public void setCategoryKey(String categoryKey) { this.categoryKey = categoryKey; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getBuyUrl() { return buyUrl; }
public void setBuyUrl(String buyUrl) { this.buyUrl = buyUrl; }
public List<ProductOfferDto> getOffers() { return offers; }
public void setOffers(List<ProductOfferDto> offers) { this.offers = offers; }
}

View File

@@ -1,4 +1,3 @@
// src/main/java/com/ballistic/gunbuilder/api/dto/GunbuilderProductDto.java
package group.goforward.battlbuilder.web.dto;
import java.math.BigDecimal;
@@ -9,15 +8,35 @@ public class ProductDto {
private String brand;
private String platform;
private String partRole;
private String categoryKey;
private BigDecimal price;
private String imageUrl;
private String buyUrl;
private String imageUrl;
public String getId() {
return id;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public void setId(String id) {
this.id = id;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getPartRole() { return partRole; }
public void setPartRole(String partRole) { this.partRole = partRole; }
public String getCategoryKey() { return categoryKey; }
public void setCategoryKey(String categoryKey) { this.categoryKey = categoryKey; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getBuyUrl() { return buyUrl; }
public void setBuyUrl(String buyUrl) { this.buyUrl = buyUrl; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
}