mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
Merge remote-tracking branch 'origin/new-classifier-service' into develop
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -166,6 +166,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user