new classifier and resolver for product mappings. also cleaned up /product endpoints

This commit is contained in:
2025-12-10 12:02:24 -05:00
parent 2049f62f99
commit ddaad50ed2
12 changed files with 628 additions and 51 deletions

View File

@@ -146,6 +146,12 @@
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,91 @@
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PlatformRule;
import java.util.regex.Pattern;
class CompiledPlatformRule {
private final Long merchantId;
private final Long brandId;
private final String rawCategoryPattern; // for DB-level pattern (optional)
private final Pattern namePattern; // compiled regex, may be null
private final int priority;
private final String targetPlatform;
static CompiledPlatformRule fromEntity(PlatformRule entity) {
Pattern compiled = null;
if (entity.getNameRegex() != null && !entity.getNameRegex().isBlank()) {
compiled = Pattern.compile(entity.getNameRegex());
}
return new CompiledPlatformRule(
entity.getMerchantId(),
entity.getBrandId(),
entity.getRawCategoryPattern(),
compiled,
entity.getPriority(),
entity.getTargetPlatform()
);
}
private CompiledPlatformRule(
Long merchantId,
Long brandId,
String rawCategoryPattern,
Pattern namePattern,
int priority,
String targetPlatform
) {
this.merchantId = merchantId;
this.brandId = brandId;
this.rawCategoryPattern = rawCategoryPattern;
this.namePattern = namePattern;
this.priority = priority;
this.targetPlatform = targetPlatform;
}
public int getPriority() {
return priority;
}
public String getTargetPlatform() {
return targetPlatform;
}
boolean matches(ProductContext ctx) {
// merchant-specific rule?
if (merchantId != null && !merchantId.equals(ctx.getMerchantId())) {
return false;
}
// brand-specific rule?
if (brandId != null && !brandId.equals(ctx.getBrandId())) {
return false;
}
// raw category pattern (simple contains or SQL-style wildcard simulation)
if (rawCategoryPattern != null && !rawCategoryPattern.isBlank()) {
String category = ctx.getRawCategoryKey();
if (category == null) {
return false;
}
// super simple: treat %pattern% as contains; you can make this smarter later
String normalizedPattern = rawCategoryPattern.replace("%", "").toLowerCase();
if (!category.toLowerCase().contains(normalizedPattern)) {
return false;
}
}
// product name regex
if (namePattern != null) {
String name = ctx.getName();
if (name == null || !namePattern.matcher(name).matches()) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,25 @@
package group.goforward.battlbuilder.catalog.classification;
/**
* Result returned by PlatformResolver.
*
* Any of the fields may be null — the importer will only overwrite
* product.platform, product.partRole, or product.configuration
* when the returned value is non-null AND non-blank.
*/
public record PlatformResolutionResult(
String platform,
String partRole,
String configuration
) {
public static PlatformResolutionResult empty() {
return new PlatformResolutionResult(null, null, null);
}
public boolean isEmpty() {
return (platform == null || platform.isBlank()) &&
(partRole == null || partRole.isBlank()) &&
(configuration == null || configuration.isBlank());
}
}

View File

@@ -0,0 +1,58 @@
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PlatformRule;
import group.goforward.battlbuilder.repos.PlatformRuleRepository;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
@Component
public class PlatformResolver {
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
private final PlatformRuleRepository ruleRepository;
private List<CompiledPlatformRule> compiledRules;
public PlatformResolver(PlatformRuleRepository ruleRepository) {
this.ruleRepository = ruleRepository;
}
@PostConstruct
public void loadRules() {
List<PlatformRule> activeRules = ruleRepository.findByActiveTrueOrderByPriorityDesc();
this.compiledRules = activeRules.stream()
.map(CompiledPlatformRule::fromEntity)
.sorted(Comparator.comparingInt(CompiledPlatformRule::getPriority).reversed())
.toList();
log.info("Loaded {} platform rules", compiledRules.size());
}
/**
* Resolves final platform.
* @param basePlatform platform from merchant mapping (may be null)
*/
public String resolve(String basePlatform, ProductContext ctx) {
String platform = basePlatform;
for (CompiledPlatformRule rule : compiledRules) {
if (rule.matches(ctx)) {
String newPlatform = rule.getTargetPlatform();
if (platform == null || !platform.equalsIgnoreCase(newPlatform)) {
log.debug("Platform override: '{}' -> '{}' for product '{}'",
platform, newPlatform, ctx.getName());
}
platform = newPlatform;
break; // first matching high-priority rule wins
}
}
return platform;
}
}

View File

@@ -0,0 +1,39 @@
package group.goforward.battlbuilder.catalog.classification;
public class ProductContext {
private final Long merchantId;
private final Long brandId;
private final String rawCategoryKey;
private final String name;
public ProductContext(Long merchantId, Long brandId, String rawCategoryKey, String name) {
this.merchantId = merchantId;
this.brandId = brandId;
this.rawCategoryKey = rawCategoryKey;
this.name = name;
}
public ProductContext(String rawCategoryKey, String name, String rawCategoryKey1, String name1, String name2, Long merchantId, Long brandId, String rawCategoryKey2, String name3) {
this.merchantId = merchantId;
this.brandId = brandId;
this.rawCategoryKey = rawCategoryKey2;
this.name = name3;
}
public Long getMerchantId() {
return merchantId;
}
public Long getBrandId() {
return brandId;
}
public String getRawCategoryKey() {
return rawCategoryKey;
}
public String getName() {
return name;
}
}

View File

@@ -3,14 +3,13 @@ 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.web.dto.ProductDto;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
@@ -32,17 +31,22 @@ public class ProductController {
this.productOfferRepository = productOfferRepository;
}
@GetMapping("/gunbuilder")
/**
* List products for the builder, filterable by platform + partRoles.
*
* GET /api/products?platform=AR-15&partRoles=UPPER&partRoles=BARREL
*/
@GetMapping
@Cacheable(
value = "gunbuilderProducts",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
)
public List<ProductSummaryDto> getGunbuilderProducts(
public List<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles
) {
long started = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: start, platform=" + platform +
System.out.println("getProducts: start, platform=" + platform +
", partRoles=" + (partRoles == null ? "null" : partRoles));
// 1) Load products (with brand pre-fetched)
@@ -54,12 +58,12 @@ public class ProductController {
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
}
long tProductsEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded products: " +
System.out.println("getProducts: loaded products: " +
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
if (products.isEmpty()) {
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
System.out.println("getProducts: 0 products in " + took + " ms");
return List.of();
}
@@ -72,7 +76,7 @@ public class ProductController {
List<ProductOffer> allOffers =
productOfferRepository.findByProductIdIn(productIds);
long tOffersEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded offers: " +
System.out.println("getProducts: loaded offers: " +
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
@@ -96,9 +100,9 @@ public class ProductController {
long tMapEnd = System.currentTimeMillis();
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
System.out.println("getProducts: mapping to DTOs took " +
(tMapEnd - tMapStart) + " ms");
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
System.out.println("getProducts: TOTAL " + took + " ms (" +
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
"map=" + (tMapEnd - tMapStart) + " ms)");
@@ -106,6 +110,11 @@ public class ProductController {
return result;
}
/**
* Offers for a single product.
*
* GET /api/products/{id}/offers
*/
@GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
@@ -130,15 +139,20 @@ public class ProductController {
return null;
}
// Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway)
// Right now: lowest price wins, regardless of stock
return offers.stream()
.filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null);
}
@GetMapping("/gunbuilder/products/{id}")
public ResponseEntity<ProductSummaryDto> getGunbuilderProductById(@PathVariable("id") Integer productId) {
/**
* Single product summary (same shape as list items).
*
* GET /api/products/{id}
*/
@GetMapping("/{id}")
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
return productRepository.findById(productId)
.map(product -> {
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);

View File

@@ -0,0 +1,114 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "platform_rules")
public class PlatformRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Optional scoping
@Column(name = "merchant_id")
private Long merchantId;
@Column(name = "brand_id")
private Long brandId;
@Column(name = "raw_category_pattern")
private String rawCategoryPattern;
@Column(name = "name_regex")
private String nameRegex;
@Column(name = "priority")
private Integer priority;
@Column(name = "active")
private Boolean active;
@Column(name = "target_platform")
private String targetPlatform;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
public void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
public void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
public Long getId() {
return id;
}
public Long getMerchantId() {
return merchantId;
}
public void setMerchantId(Long merchantId) {
this.merchantId = merchantId;
}
public Long getBrandId() {
return brandId;
}
public void setBrandId(Long brandId) {
this.brandId = brandId;
}
public String getRawCategoryPattern() {
return rawCategoryPattern;
}
public void setRawCategoryPattern(String rawCategoryPattern) {
this.rawCategoryPattern = rawCategoryPattern;
}
public String getNameRegex() {
return nameRegex;
}
public void setNameRegex(String nameRegex) {
this.nameRegex = nameRegex;
}
public Integer getPriority() {
return priority != null ? priority : 0;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
public String getTargetPlatform() {
return targetPlatform;
}
public void setTargetPlatform(String targetPlatform) {
this.targetPlatform = targetPlatform;
}
}

View File

@@ -0,0 +1,16 @@
package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
List<MerchantCategoryMap> findAllByMerchantIdAndRawCategoryAndEnabledTrue(
Integer merchantId,
String rawCategory
);
}

View File

@@ -0,0 +1,11 @@
package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.PlatformRule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
List<PlatformRule> findByActiveTrueOrderByPriorityDesc();
}

View File

@@ -0,0 +1,15 @@
package group.goforward.battlbuilder.services;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
public interface CategoryClassificationService {
record Result(
String platform, // e.g. "AR-15"
String partRole, // e.g. "muzzle-device"
String rawCategoryKey // e.g. "Rifle Parts > Muzzle Devices > Flash Hiders"
) {}
Result classify(Merchant merchant, MerchantFeedRow row);
}

View File

@@ -0,0 +1,174 @@
package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.services.CategoryClassificationService;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public CategoryClassificationServiceImpl(MerchantCategoryMapRepository merchantCategoryMapRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
}
@Override
public Result classify(Merchant merchant, MerchantFeedRow row) {
String rawCategoryKey = buildRawCategoryKey(row);
// 1) Platform from mapping (if present), else infer
String platform = resolvePlatformFromMapping(merchant, rawCategoryKey)
.orElseGet(() -> inferPlatform(row));
if (platform == null) {
platform = "AR-15";
}
// 2) Part role from mapping (if present), else infer
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKey, platform)
.orElseGet(() -> inferPartRole(row));
if (partRole == null || partRole.isBlank()) {
partRole = "UNKNOWN";
} else {
partRole = partRole.trim();
}
return new Result(platform, partRole, rawCategoryKey);
}
private Optional<String> resolvePlatformFromMapping(Merchant merchant, String rawCategoryKey) {
if (rawCategoryKey == null) return Optional.empty();
List<MerchantCategoryMap> mappings =
merchantCategoryMapRepository.findAllByMerchantIdAndRawCategoryAndEnabledTrue(
merchant.getId(), rawCategoryKey
);
return mappings.stream()
.sorted(Comparator
.comparing((MerchantCategoryMap m) -> m.getPlatform() == null) // exact platform last
.thenComparing(MerchantCategoryMap::getConfidence, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(MerchantCategoryMap::getId))
.map(MerchantCategoryMap::getPlatform)
.filter(p -> p != null && !p.isBlank())
.findFirst();
}
private Optional<String> resolvePartRoleFromMapping(Merchant merchant,
String rawCategoryKey,
String platform) {
if (rawCategoryKey == null) return Optional.empty();
List<MerchantCategoryMap> mappings =
merchantCategoryMapRepository.findAllByMerchantIdAndRawCategoryAndEnabledTrue(
merchant.getId(), rawCategoryKey
);
return mappings.stream()
.filter(m -> m.getPartRole() != null && !m.getPartRole().isBlank())
// prefer explicit platform, but allow null platform
.sorted(Comparator
.comparing((MerchantCategoryMap m) -> !platformEquals(m.getPlatform(), platform))
.thenComparing(MerchantCategoryMap::getConfidence, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(MerchantCategoryMap::getId))
.map(MerchantCategoryMap::getPartRole)
.findFirst();
}
private boolean platformEquals(String a, String b) {
if (a == null || b == null) return false;
return a.equalsIgnoreCase(b);
}
// You can reuse logic from MerchantFeedImportServiceImpl, but Ill inline equivalents here
private String buildRawCategoryKey(MerchantFeedRow row) {
String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
StringBuilder sb = new StringBuilder();
if (dept != null) sb.append(dept);
if (cat != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(cat);
}
if (sub != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(sub);
}
String result = sb.toString();
return result.isBlank() ? null : result;
}
private String inferPlatform(MerchantFeedRow row) {
String department = coalesce(
trimOrNull(row.department()),
trimOrNull(row.category())
);
if (department == null) return null;
String lower = department.toLowerCase(Locale.ROOT);
if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15";
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47";
return "AR-15";
}
private String inferPartRole(MerchantFeedRow row) {
String cat = coalesce(
trimOrNull(row.subCategory()),
trimOrNull(row.category())
);
if (cat == null) return null;
String lower = cat.toLowerCase(Locale.ROOT);
if (lower.contains("handguard") || lower.contains("rail")) {
return "handguard";
}
if (lower.contains("barrel")) {
return "barrel";
}
if (lower.contains("upper")) {
return "upper-receiver";
}
if (lower.contains("lower")) {
return "lower-receiver";
}
if (lower.contains("magazine") || lower.contains("mag")) {
return "magazine";
}
if (lower.contains("stock") || lower.contains("buttstock")) {
return "stock";
}
if (lower.contains("grip")) {
return "grip";
}
return "unknown";
}
private String trimOrNull(String v) {
if (v == null) return null;
String t = v.trim();
return t.isEmpty() ? null : t;
}
private String coalesce(String... values) {
if (values == null) return null;
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return null;
}
}

View File

@@ -1,17 +1,20 @@
// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted
package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMapping;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
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.MerchantCategoryMappingService;
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
import group.goforward.battlbuilder.catalog.classification.ProductContext;
import group.goforward.battlbuilder.services.MerchantFeedImportService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
@@ -45,20 +48,20 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
private final MerchantRepository merchantRepository;
private final BrandRepository brandRepository;
private final ProductRepository productRepository;
private final MerchantCategoryMappingService merchantCategoryMappingService;
private final PlatformResolver platformResolver;
private final ProductOfferRepository productOfferRepository;
public MerchantFeedImportServiceImpl(
MerchantRepository merchantRepository,
BrandRepository brandRepository,
ProductRepository productRepository,
MerchantCategoryMappingService merchantCategoryMappingService,
PlatformResolver platformResolver,
ProductOfferRepository productOfferRepository
) {
this.merchantRepository = merchantRepository;
this.brandRepository = brandRepository;
this.productRepository = productRepository;
this.merchantCategoryMappingService = merchantCategoryMappingService;
this.platformResolver = platformResolver;
this.productOfferRepository = productOfferRepository;
}
@@ -187,43 +190,45 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setMpn(mpn);
p.setUpc(null); // placeholder
// ---------- PLATFORM ----------
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
String platform = inferPlatform(row);
p.setPlatform(platform != null ? platform : "AR-15");
}
// ---------- RAW CATEGORY KEY ----------
String rawCategoryKey = buildRawCategoryKey(row);
p.setRawCategoryKey(rawCategoryKey);
// ---------- PART ROLE (mapping + fallback) ----------
String partRole = null;
// ---------- PLATFORM (base heuristic + rule resolver) ----------
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
String basePlatform = inferPlatform(row);
// 1) First try merchant category mapping
if (rawCategoryKey != null) {
MerchantCategoryMapping mapping =
merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey);
Long merchantId = merchant.getId() != null
? merchant.getId().longValue()
: null;
if (mapping != null &&
mapping.getMappedPartRole() != null &&
!mapping.getMappedPartRole().isBlank()) {
partRole = mapping.getMappedPartRole().trim();
}
Long brandId = (p.getBrand() != null && p.getBrand().getId() != null)
? p.getBrand().getId().longValue()
: null;
ProductContext ctx = new ProductContext(
merchantId,
brandId,
rawCategoryKey,
p.getName()
);
String resolvedPlatform = platformResolver.resolve(basePlatform, ctx);
String finalPlatform = resolvedPlatform != null
? resolvedPlatform
: (basePlatform != null ? basePlatform : "AR-15");
p.setPlatform(finalPlatform);
}
// 2) Fallback to keyword-based inference
if (partRole == null || partRole.isBlank()) {
partRole = inferPartRole(row);
}
// 3) Normalize or default to UNKNOWN
// ---------- PART ROLE (keyword-based for now) ----------
String partRole = inferPartRole(row);
if (partRole == null || partRole.isBlank()) {
partRole = "UNKNOWN";
} else {
partRole = partRole.trim();
}
p.setPartRole(partRole);
// ---------- IMPORT STATUS ----------
@@ -419,9 +424,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
char[] delimiters = new char[]{'\t', ',', ';'};
List<String> requiredHeaders =
Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
// Try a few common delimiters, but only require the SKU header to be present.
char[] delimiters = new char[]{'\t', ',', ';', '|'};
List<String> requiredHeaders = Arrays.asList("SKU");
Exception lastException = null;
@@ -438,7 +443,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Map<String, Integer> headerMap = parser.getHeaderMap();
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
log.info("Detected delimiter '{}' for feed {} with headers {}",
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)),
feedUrl,
headerMap.keySet());
return CSVFormat.DEFAULT.builder()
.setDelimiter(delimiter)
@@ -459,10 +467,16 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
}
}
if (lastException != null) {
throw lastException;
}
throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl);
// If we got here, either all attempts failed or none matched the headers we expected.
// Fall back to a sensible default (comma) instead of failing the whole import.
log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl);
return CSVFormat.DEFAULT.builder()
.setDelimiter(',')
.setHeader()
.setSkipHeaderRecord(true)
.setIgnoreSurroundingSpaces(true)
.setTrim(true)
.build();
}
private List<MerchantFeedRow> readFeedRowsForMerchant(Merchant merchant) {