diff --git a/pom.xml b/pom.xml
index ccbf697..c046aaf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -146,6 +146,12 @@
org.glassfish.web
jakarta.servlet.jsp.jstl
+
+ org.testng
+ testng
+ RELEASE
+ compile
+
diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/CompiledPlatformRule.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/CompiledPlatformRule.java
new file mode 100644
index 0000000..b190258
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/CompiledPlatformRule.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java
new file mode 100644
index 0000000..1246511
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java
@@ -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());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java
new file mode 100644
index 0000000..e63b6f3
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java
@@ -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 compiledRules;
+
+ public PlatformResolver(PlatformRuleRepository ruleRepository) {
+ this.ruleRepository = ruleRepository;
+ }
+
+ @PostConstruct
+ public void loadRules() {
+ List 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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/ProductContext.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/ProductContext.java
new file mode 100644
index 0000000..d36b881
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/ProductContext.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java
index 666af49..6fa6af8 100644
--- a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java
+++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java
@@ -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 getGunbuilderProducts(
+ public List getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List 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 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> 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 getOffersForProduct(@PathVariable("id") Integer productId) {
List 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 getGunbuilderProductById(@PathVariable("id") Integer productId) {
+ /**
+ * Single product summary (same shape as list items).
+ *
+ * GET /api/products/{id}
+ */
+ @GetMapping("/{id}")
+ public ResponseEntity getProductById(@PathVariable("id") Integer productId) {
return productRepository.findById(productId)
.map(product -> {
List offers = productOfferRepository.findByProductId(productId);
diff --git a/src/main/java/group/goforward/battlbuilder/model/PlatformRule.java b/src/main/java/group/goforward/battlbuilder/model/PlatformRule.java
new file mode 100644
index 0000000..bb6b82c
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/model/PlatformRule.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java
new file mode 100644
index 0000000..8292d47
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java
@@ -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 {
+
+ List findAllByMerchantIdAndRawCategoryAndEnabledTrue(
+ Integer merchantId,
+ String rawCategory
+ );
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java b/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java
new file mode 100644
index 0000000..f753117
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java
@@ -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 {
+
+ List findByActiveTrueOrderByPriorityDesc();
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java b/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java
new file mode 100644
index 0000000..0dd42ac
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java
@@ -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);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java
new file mode 100644
index 0000000..7282d57
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java
@@ -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 resolvePlatformFromMapping(Merchant merchant, String rawCategoryKey) {
+ if (rawCategoryKey == null) return Optional.empty();
+
+ List 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 resolvePartRoleFromMapping(Merchant merchant,
+ String rawCategoryKey,
+ String platform) {
+ if (rawCategoryKey == null) return Optional.empty();
+
+ List 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 I’ll 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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java
index b983b37..81591ab 100644
--- a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java
+++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java
@@ -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 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 requiredHeaders = Arrays.asList("SKU");
Exception lastException = null;
@@ -438,7 +443,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Map 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 readFeedRowsForMerchant(Merchant merchant) {