From ddaad50ed2e49766cbcaa5bf18d8e8fc2d2a47f0 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 10 Dec 2025 12:02:24 -0500 Subject: [PATCH] new classifier and resolver for product mappings. also cleaned up /product endpoints --- pom.xml | 6 + .../classification/CompiledPlatformRule.java | 91 +++++++++ .../PlatformResolutionResult.java | 25 +++ .../classification/PlatformResolver.java | 58 ++++++ .../classification/ProductContext.java | 39 ++++ .../controllers/ProductController.java | 42 +++-- .../battlbuilder/model/PlatformRule.java | 114 ++++++++++++ .../repos/MerchantCategoryMapRepository.java | 16 ++ .../repos/PlatformRuleRepository.java | 11 ++ .../CategoryClassificationService.java | 15 ++ .../CategoryClassificationServiceImpl.java | 174 ++++++++++++++++++ .../impl/MerchantFeedImportServiceImpl.java | 88 +++++---- 12 files changed, 628 insertions(+), 51 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/classification/CompiledPlatformRule.java create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolutionResult.java create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/classification/ProductContext.java create mode 100644 src/main/java/group/goforward/battlbuilder/model/PlatformRule.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java 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) {