mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-21 01:01:05 -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;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
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.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
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 group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVParser;
|
import org.apache.commons.csv.CSVParser;
|
||||||
@@ -30,17 +38,18 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merchant feed ETL + offer sync.
|
* MerchantFeedImportServiceImpl
|
||||||
*
|
*
|
||||||
* - importMerchantFeed: full ETL (products + offers)
|
* RESPONSIBILITIES:
|
||||||
* - syncOffersOnly: only refresh offers/prices/stock from an offers feed
|
* - 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:
|
* NON-GOALS:
|
||||||
* Classification (platform + partRole + rawCategoryKey) must run through CategoryClassificationService
|
* - Perfect classification (that’s iterative)
|
||||||
* so we respect:
|
* - UI-level filtering (handled later)
|
||||||
* 1) merchant_category_mappings (admin UI mapping)
|
|
||||||
* 2) rule-based resolver
|
|
||||||
* 3) fallback inference
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -51,26 +60,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final BrandRepository brandRepository;
|
private final BrandRepository brandRepository;
|
||||||
private final ProductRepository productRepository;
|
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 ProductOfferRepository productOfferRepository;
|
||||||
private final CategoryClassificationService categoryClassificationService;
|
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(
|
public MerchantFeedImportServiceImpl(
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
BrandRepository brandRepository,
|
BrandRepository brandRepository,
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
ProductOfferRepository productOfferRepository,
|
PlatformResolver platformResolver,
|
||||||
CategoryClassificationService categoryClassificationService
|
ProductOfferRepository productOfferRepository
|
||||||
) {
|
) {
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.brandRepository = brandRepository;
|
this.brandRepository = brandRepository;
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
|
this.platformResolver = platformResolver;
|
||||||
this.productOfferRepository = productOfferRepository;
|
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
|
@Override
|
||||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||||
@@ -80,26 +98,52 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||||
|
|
||||||
|
// Read & parse CSV feed into structured rows
|
||||||
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
||||||
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
||||||
|
|
||||||
|
int processed = 0;
|
||||||
|
int notSupported = 0;
|
||||||
|
|
||||||
|
// Main ETL loop
|
||||||
for (MerchantFeedRow row : rows) {
|
for (MerchantFeedRow row : rows) {
|
||||||
|
|
||||||
|
// 1) Resolve brand (create if missing)
|
||||||
Brand brand = resolveBrand(row);
|
Brand brand = resolveBrand(row);
|
||||||
|
|
||||||
|
// 2) Upsert product + offer
|
||||||
Product p = upsertProduct(merchant, brand, row);
|
Product p = upsertProduct(merchant, brand, row);
|
||||||
|
|
||||||
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
|
processed++;
|
||||||
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
|
||||||
|
// 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());
|
merchant.setLastFullImportAt(OffsetDateTime.now());
|
||||||
merchantRepository.save(merchant);
|
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) {
|
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||||
String mpn = trimOrNull(row.manufacturerId());
|
String mpn = trimOrNull(row.manufacturerId());
|
||||||
@@ -126,10 +170,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
brand.getName(), mpn, upc, candidates.get(0).getId());
|
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||||
}
|
}
|
||||||
p = candidates.get(0);
|
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);
|
updateProductFromRow(p, merchant, row, isNew);
|
||||||
|
|
||||||
Product saved = productRepository.save(p);
|
Product saved = productRepository.save(p);
|
||||||
@@ -139,12 +182,21 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
return saved;
|
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,
|
private void updateProductFromRow(Product p,
|
||||||
Merchant merchant,
|
Merchant merchant,
|
||||||
MerchantFeedRow row,
|
MerchantFeedRow row,
|
||||||
boolean isNew) {
|
boolean isNew) {
|
||||||
|
|
||||||
// ---------- NAME ----------
|
// ---------- NAME ----------
|
||||||
|
// Prefer productName, fallback to descriptions or SKU
|
||||||
|
//
|
||||||
String name = coalesce(
|
String name = coalesce(
|
||||||
trimOrNull(row.productName()),
|
trimOrNull(row.productName()),
|
||||||
trimOrNull(row.shortDescription()),
|
trimOrNull(row.shortDescription()),
|
||||||
@@ -155,12 +207,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
p.setName(name);
|
p.setName(name);
|
||||||
|
|
||||||
// ---------- SLUG ----------
|
// ---------- SLUG ----------
|
||||||
|
// Only generate once (unless missing)
|
||||||
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
||||||
String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku()));
|
String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku()));
|
||||||
if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis();
|
if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis();
|
||||||
|
|
||||||
String slug = baseForSlug
|
String slug = baseForSlug
|
||||||
.toLowerCase(Locale.ROOT)
|
.toLowerCase()
|
||||||
.replaceAll("[^a-z0-9]+", "-")
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
.replaceAll("(^-|-$)", "");
|
.replaceAll("(^-|-$)", "");
|
||||||
|
|
||||||
@@ -186,26 +239,55 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
p.setMpn(mpn);
|
p.setMpn(mpn);
|
||||||
p.setUpc(null); // placeholder
|
p.setUpc(null); // placeholder
|
||||||
|
|
||||||
// ---------- CLASSIFICATION (rawCategoryKey + platform + partRole) ----------
|
// ---------- RAW CATEGORY KEY ----------
|
||||||
CategoryClassificationService.Result r = categoryClassificationService.classify(merchant, row);
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
|
p.setRawCategoryKey(rawCategoryKey);
|
||||||
|
|
||||||
// Always persist the rawCategoryKey coming out of classification (consistent keying)
|
// ---------- PLATFORM RESOLUTION ----------
|
||||||
p.setRawCategoryKey(r.rawCategoryKey());
|
//
|
||||||
|
// 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.
|
Long mId = merchant.getId() == null ? null : merchant.getId().longValue();
|
||||||
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked()) || p.getPlatform() == null || p.getPlatform().isBlank()) {
|
Long bId = (p.getBrand() != null && p.getBrand().getId() != null) ? p.getBrand().getId().longValue() : null;
|
||||||
String platform = (r.platform() == null || r.platform().isBlank()) ? "AR-15" : r.platform();
|
|
||||||
p.setPlatform(platform);
|
// 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),
|
// ---------- PART ROLE (TEMPORARY) ----------
|
||||||
// but if something returns null/blank, treat as unknown.
|
// This is intentionally weak — PartRoleResolver + mappings improve this later
|
||||||
String partRole = (r.partRole() == null) ? "unknown" : r.partRole().trim();
|
String partRole = inferPartRole(row);
|
||||||
if (partRole.isBlank()) partRole = "unknown";
|
if (partRole == null || partRole.isBlank()) {
|
||||||
|
partRole = "UNKNOWN";
|
||||||
|
} else {
|
||||||
|
partRole = partRole.trim();
|
||||||
|
}
|
||||||
p.setPartRole(partRole);
|
p.setPartRole(partRole);
|
||||||
|
|
||||||
// ---------- IMPORT STATUS ----------
|
// ---------- 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);
|
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||||
} else {
|
} else {
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
@@ -303,15 +385,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
merchant.setLastOfferSyncAt(OffsetDateTime.now());
|
merchant.setLastOfferSyncAt(OffsetDateTime.now());
|
||||||
merchantRepository.save(merchant);
|
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());
|
merchantId, rows.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
|
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
|
||||||
String avantlinkProductId = trimOrNull(row.get("SKU"));
|
String avantlinkProductId = trimOrNull(row.get("SKU"));
|
||||||
if (avantlinkProductId == null || avantlinkProductId.isBlank()) {
|
if (avantlinkProductId == null || avantlinkProductId.isBlank()) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProductOffer offer = productOfferRepository
|
ProductOffer offer = productOfferRepository
|
||||||
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
.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")) {
|
if (lower.contains("false") || lower.contains("no") || lower.contains("0") || lower.contains("out of stock")) {
|
||||||
return Boolean.FALSE;
|
return Boolean.FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean.FALSE;
|
return Boolean.FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +476,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
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', ',', ';', '|'};
|
char[] delimiters = new char[]{'\t', ',', ';', '|'};
|
||||||
List<String> requiredHeaders = Collections.singletonList("SKU");
|
List<String> requiredHeaders = Arrays.asList("SKU");
|
||||||
|
|
||||||
Exception lastException = null;
|
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);
|
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()
|
return CSVFormat.DEFAULT.builder()
|
||||||
.setDelimiter(',')
|
.setDelimiter(',')
|
||||||
.setHeader()
|
.setHeader()
|
||||||
@@ -541,7 +615,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private String getCsvValue(CSVRecord rec, String header) {
|
private String getCsvValue(CSVRecord rec, String header) {
|
||||||
if (rec == null || header == null) return null;
|
if (rec == null || header == null) return null;
|
||||||
if (!rec.isMapped(header)) return null;
|
if (!rec.isMapped(header)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return rec.get(header);
|
return rec.get(header);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
@@ -574,4 +647,83 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
return candidate;
|
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;
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -9,15 +8,35 @@ public class ProductDto {
|
|||||||
private String brand;
|
private String brand;
|
||||||
private String platform;
|
private String platform;
|
||||||
private String partRole;
|
private String partRole;
|
||||||
|
private String categoryKey;
|
||||||
private BigDecimal price;
|
private BigDecimal price;
|
||||||
private String imageUrl;
|
|
||||||
private String buyUrl;
|
private String buyUrl;
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
public String getId() {
|
public String getId() { return id; }
|
||||||
return id;
|
public void setId(String id) { this.id = id; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setId(String id) {
|
public String getName() { return name; }
|
||||||
this.id = id;
|
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