mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
new classifier and resolver for product mappings. also cleaned up /product endpoints
This commit is contained in:
6
pom.xml
6
pom.xml
@@ -146,6 +146,12 @@
|
|||||||
<groupId>org.glassfish.web</groupId>
|
<groupId>org.glassfish.web</groupId>
|
||||||
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
|
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testng</groupId>
|
||||||
|
<artifactId>testng</artifactId>
|
||||||
|
<version>RELEASE</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<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.Product;
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
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.repos.ProductRepository;
|
||||||
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -32,17 +31,22 @@ public class ProductController {
|
|||||||
this.productOfferRepository = productOfferRepository;
|
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(
|
@Cacheable(
|
||||||
value = "gunbuilderProducts",
|
value = "gunbuilderProducts",
|
||||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
||||||
)
|
)
|
||||||
public List<ProductSummaryDto> getGunbuilderProducts(
|
public List<ProductSummaryDto> getProducts(
|
||||||
@RequestParam(defaultValue = "AR-15") String platform,
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||||
) {
|
) {
|
||||||
long started = System.currentTimeMillis();
|
long started = System.currentTimeMillis();
|
||||||
System.out.println("getGunbuilderProducts: start, platform=" + platform +
|
System.out.println("getProducts: start, platform=" + platform +
|
||||||
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
||||||
|
|
||||||
// 1) Load products (with brand pre-fetched)
|
// 1) Load products (with brand pre-fetched)
|
||||||
@@ -54,12 +58,12 @@ public class ProductController {
|
|||||||
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||||
}
|
}
|
||||||
long tProductsEnd = System.currentTimeMillis();
|
long tProductsEnd = System.currentTimeMillis();
|
||||||
System.out.println("getGunbuilderProducts: loaded products: " +
|
System.out.println("getProducts: loaded products: " +
|
||||||
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
||||||
|
|
||||||
if (products.isEmpty()) {
|
if (products.isEmpty()) {
|
||||||
long took = System.currentTimeMillis() - started;
|
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();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ public class ProductController {
|
|||||||
List<ProductOffer> allOffers =
|
List<ProductOffer> allOffers =
|
||||||
productOfferRepository.findByProductIdIn(productIds);
|
productOfferRepository.findByProductIdIn(productIds);
|
||||||
long tOffersEnd = System.currentTimeMillis();
|
long tOffersEnd = System.currentTimeMillis();
|
||||||
System.out.println("getGunbuilderProducts: loaded offers: " +
|
System.out.println("getProducts: loaded offers: " +
|
||||||
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
@@ -96,9 +100,9 @@ public class ProductController {
|
|||||||
long tMapEnd = System.currentTimeMillis();
|
long tMapEnd = System.currentTimeMillis();
|
||||||
long took = System.currentTimeMillis() - started;
|
long took = System.currentTimeMillis() - started;
|
||||||
|
|
||||||
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
|
System.out.println("getProducts: mapping to DTOs took " +
|
||||||
(tMapEnd - tMapStart) + " ms");
|
(tMapEnd - tMapStart) + " ms");
|
||||||
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
|
System.out.println("getProducts: TOTAL " + took + " ms (" +
|
||||||
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
|
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
|
||||||
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
|
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
|
||||||
"map=" + (tMapEnd - tMapStart) + " ms)");
|
"map=" + (tMapEnd - tMapStart) + " ms)");
|
||||||
@@ -106,6 +110,11 @@ public class ProductController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers for a single product.
|
||||||
|
*
|
||||||
|
* GET /api/products/{id}/offers
|
||||||
|
*/
|
||||||
@GetMapping("/{id}/offers")
|
@GetMapping("/{id}/offers")
|
||||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
||||||
@@ -130,15 +139,20 @@ public class ProductController {
|
|||||||
return null;
|
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()
|
return offers.stream()
|
||||||
.filter(o -> o.getEffectivePrice() != null)
|
.filter(o -> o.getEffectivePrice() != null)
|
||||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||||
.orElse(null);
|
.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)
|
return productRepository.findById(productId)
|
||||||
.map(product -> {
|
.map(product -> {
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
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;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Brand;
|
import group.goforward.battlbuilder.model.Brand;
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMapping;
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
import group.goforward.battlbuilder.repos.BrandRepository;
|
import group.goforward.battlbuilder.repos.BrandRepository;
|
||||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
import group.goforward.battlbuilder.repos.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
import group.goforward.battlbuilder.services.MerchantCategoryMappingService;
|
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
|
||||||
|
import group.goforward.battlbuilder.catalog.classification.ProductContext;
|
||||||
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVParser;
|
import org.apache.commons.csv.CSVParser;
|
||||||
@@ -45,20 +48,20 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final BrandRepository brandRepository;
|
private final BrandRepository brandRepository;
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
private final PlatformResolver platformResolver;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(
|
public MerchantFeedImportServiceImpl(
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
BrandRepository brandRepository,
|
BrandRepository brandRepository,
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
PlatformResolver platformResolver,
|
||||||
ProductOfferRepository productOfferRepository
|
ProductOfferRepository productOfferRepository
|
||||||
) {
|
) {
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.brandRepository = brandRepository;
|
this.brandRepository = brandRepository;
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
this.platformResolver = platformResolver;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,43 +190,45 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
p.setMpn(mpn);
|
p.setMpn(mpn);
|
||||||
p.setUpc(null); // placeholder
|
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 ----------
|
// ---------- RAW CATEGORY KEY ----------
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
p.setRawCategoryKey(rawCategoryKey);
|
p.setRawCategoryKey(rawCategoryKey);
|
||||||
|
|
||||||
// ---------- PART ROLE (mapping + fallback) ----------
|
// ---------- PLATFORM (base heuristic + rule resolver) ----------
|
||||||
String partRole = null;
|
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
||||||
|
String basePlatform = inferPlatform(row);
|
||||||
|
|
||||||
// 1) First try merchant category mapping
|
Long merchantId = merchant.getId() != null
|
||||||
if (rawCategoryKey != null) {
|
? merchant.getId().longValue()
|
||||||
MerchantCategoryMapping mapping =
|
: null;
|
||||||
merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey);
|
|
||||||
|
|
||||||
if (mapping != null &&
|
Long brandId = (p.getBrand() != null && p.getBrand().getId() != null)
|
||||||
mapping.getMappedPartRole() != null &&
|
? p.getBrand().getId().longValue()
|
||||||
!mapping.getMappedPartRole().isBlank()) {
|
: null;
|
||||||
partRole = mapping.getMappedPartRole().trim();
|
|
||||||
}
|
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
|
// ---------- PART ROLE (keyword-based for now) ----------
|
||||||
if (partRole == null || partRole.isBlank()) {
|
String partRole = inferPartRole(row);
|
||||||
partRole = inferPartRole(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Normalize or default to UNKNOWN
|
|
||||||
if (partRole == null || partRole.isBlank()) {
|
if (partRole == null || partRole.isBlank()) {
|
||||||
partRole = "UNKNOWN";
|
partRole = "UNKNOWN";
|
||||||
} else {
|
} else {
|
||||||
partRole = partRole.trim();
|
partRole = partRole.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
p.setPartRole(partRole);
|
p.setPartRole(partRole);
|
||||||
|
|
||||||
// ---------- IMPORT STATUS ----------
|
// ---------- IMPORT STATUS ----------
|
||||||
@@ -419,9 +424,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
||||||
char[] delimiters = new char[]{'\t', ',', ';'};
|
// Try a few common delimiters, but only require the SKU header to be present.
|
||||||
List<String> requiredHeaders =
|
char[] delimiters = new char[]{'\t', ',', ';', '|'};
|
||||||
Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
|
List<String> requiredHeaders = Arrays.asList("SKU");
|
||||||
|
|
||||||
Exception lastException = null;
|
Exception lastException = null;
|
||||||
|
|
||||||
@@ -438,7 +443,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
|
|
||||||
Map<String, Integer> headerMap = parser.getHeaderMap();
|
Map<String, Integer> headerMap = parser.getHeaderMap();
|
||||||
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
|
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()
|
return CSVFormat.DEFAULT.builder()
|
||||||
.setDelimiter(delimiter)
|
.setDelimiter(delimiter)
|
||||||
@@ -459,10 +467,16 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastException != null) {
|
// If we got here, either all attempts failed or none matched the headers we expected.
|
||||||
throw lastException;
|
// 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);
|
||||||
throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl);
|
return CSVFormat.DEFAULT.builder()
|
||||||
|
.setDelimiter(',')
|
||||||
|
.setHeader()
|
||||||
|
.setSkipHeaderRecord(true)
|
||||||
|
.setIgnoreSurroundingSpaces(true)
|
||||||
|
.setTrim(true)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<MerchantFeedRow> readFeedRowsForMerchant(Merchant merchant) {
|
private List<MerchantFeedRow> readFeedRowsForMerchant(Merchant merchant) {
|
||||||
|
|||||||
Reference in New Issue
Block a user