mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (that’s 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";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user