mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
holy shit. fixed a lot. new rules engine driven by db. project runs
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package group.goforward.battlbuilder.classification;
|
||||
package group.goforward.battlbuilder.catalog.classification;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||
import group.goforward.battlbuilder.repos.PartRoleRuleRepository;
|
||||
@@ -68,7 +68,7 @@ public class PartRoleResolver {
|
||||
if (role == null) return null;
|
||||
String t = role.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
return t.toLowerCase(Locale.ROOT);
|
||||
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
|
||||
@@ -2,57 +2,138 @@ 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 jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/**
|
||||
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
||||
* using explicit DB-backed rules.
|
||||
*
|
||||
* Conservative approach:
|
||||
* - If a rule matches, return its target_platform
|
||||
* - If nothing matches, return null and let the caller decide fallback behavior
|
||||
*/
|
||||
@Component
|
||||
public class PlatformResolver {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
||||
|
||||
private final PlatformRuleRepository ruleRepository;
|
||||
private List<CompiledPlatformRule> compiledRules;
|
||||
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
||||
|
||||
public PlatformResolver(PlatformRuleRepository ruleRepository) {
|
||||
this.ruleRepository = ruleRepository;
|
||||
private final PlatformRuleRepository repo;
|
||||
private final List<CompiledRule> rules = new ArrayList<>();
|
||||
|
||||
public PlatformResolver(PlatformRuleRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void loadRules() {
|
||||
List<PlatformRule> activeRules = ruleRepository.findByActiveTrueOrderByPriorityDesc();
|
||||
this.compiledRules = activeRules.stream()
|
||||
.map(CompiledPlatformRule::fromEntity)
|
||||
.sorted(Comparator.comparingInt(CompiledPlatformRule::getPriority).reversed())
|
||||
.toList();
|
||||
public void load() {
|
||||
rules.clear();
|
||||
|
||||
log.info("Loaded {} platform rules", compiledRules.size());
|
||||
}
|
||||
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
|
||||
/**
|
||||
* Resolves final platform.
|
||||
* @param basePlatform platform from merchant mapping (may be null)
|
||||
*/
|
||||
public String resolve(String basePlatform, ProductContext ctx) {
|
||||
String platform = basePlatform;
|
||||
for (PlatformRule r : active) {
|
||||
try {
|
||||
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
||||
Pattern name = compileNullable(r.getNameRegex());
|
||||
String target = normalizePlatform(r.getTargetPlatform());
|
||||
|
||||
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());
|
||||
// If a rule has no matchers, it's useless — skip it.
|
||||
if (rawCat == null && name == null) {
|
||||
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
|
||||
continue;
|
||||
}
|
||||
platform = newPlatform;
|
||||
break; // first matching high-priority rule wins
|
||||
|
||||
if (target == null || target.isBlank()) {
|
||||
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.add(new CompiledRule(
|
||||
r.getId(),
|
||||
r.getMerchantId(),
|
||||
r.getBrandId(),
|
||||
rawCat,
|
||||
name,
|
||||
target
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return platform;
|
||||
log.info("Loaded {} platform rules", rules.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
|
||||
*/
|
||||
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
|
||||
String text = safe(productName) + " " + safe(rawCategoryKey);
|
||||
|
||||
for (CompiledRule r : rules) {
|
||||
if (!r.appliesToMerchant(merchantId)) continue;
|
||||
if (!r.appliesToBrand(brandId)) continue;
|
||||
|
||||
if (r.matches(text)) {
|
||||
return r.targetPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private static Pattern compileNullable(String regex) {
|
||||
if (regex == null || regex.isBlank()) return null;
|
||||
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
private static String normalizePlatform(String platform) {
|
||||
if (platform == null) return null;
|
||||
String t = platform.trim();
|
||||
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Internal model
|
||||
// -----------------------------
|
||||
|
||||
private record CompiledRule(
|
||||
Long id,
|
||||
Long merchantId,
|
||||
Long brandId,
|
||||
Pattern rawCategoryPattern,
|
||||
Pattern namePattern,
|
||||
String targetPlatform
|
||||
) {
|
||||
boolean appliesToMerchant(Long merchantId) {
|
||||
return this.merchantId == null || this.merchantId.equals(merchantId);
|
||||
}
|
||||
|
||||
boolean appliesToBrand(Long brandId) {
|
||||
return this.brandId == null || this.brandId.equals(brandId);
|
||||
}
|
||||
|
||||
boolean matches(String text) {
|
||||
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
||||
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package group.goforward.battlbuilder.controllers;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||
import group.goforward.battlbuilder.repos.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.repos.PartRoleMappingRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/builder")
|
||||
@CrossOrigin
|
||||
public class BuilderBootstrapController {
|
||||
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
private final PartRoleMappingRepository mappingRepository;
|
||||
|
||||
public BuilderBootstrapController(
|
||||
PartCategoryRepository partCategoryRepository,
|
||||
PartRoleMappingRepository mappingRepository
|
||||
) {
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
this.mappingRepository = mappingRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder bootstrap payload.
|
||||
*
|
||||
* Returns:
|
||||
* - categories: ordered list for UI navigation
|
||||
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
||||
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
||||
*/
|
||||
@GetMapping("/bootstrap")
|
||||
public BuilderBootstrapDto bootstrap(
|
||||
@RequestParam(defaultValue = "AR-15") String platform
|
||||
) {
|
||||
final String platformNorm = normalizePlatform(platform);
|
||||
|
||||
// 1) Categories in display order
|
||||
List<PartCategoryDto> categories = partCategoryRepository
|
||||
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||
.stream()
|
||||
.map(pc -> new PartCategoryDto(
|
||||
pc.getId(),
|
||||
pc.getSlug(),
|
||||
pc.getName(),
|
||||
pc.getDescription(),
|
||||
pc.getGroupName(),
|
||||
pc.getSortOrder()
|
||||
))
|
||||
.toList();
|
||||
|
||||
// 2) Role -> CategorySlug mapping (platform-scoped)
|
||||
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
||||
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
||||
|
||||
List<PartRoleMapping> mappings = mappingRepository
|
||||
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
||||
|
||||
for (PartRoleMapping m : mappings) {
|
||||
String roleKey = normalizePartRole(m.getPartRole());
|
||||
if (roleKey == null || roleKey.isBlank()) continue;
|
||||
|
||||
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
||||
|
||||
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
||||
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
||||
}
|
||||
|
||||
// 3) CategorySlug -> Roles (derived)
|
||||
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
||||
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
||||
}
|
||||
|
||||
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
||||
}
|
||||
|
||||
private String normalizePartRole(String role) {
|
||||
if (role == null) return null;
|
||||
String r = role.trim();
|
||||
if (r.isEmpty()) return null;
|
||||
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
private String normalizePlatform(String platform) {
|
||||
if (platform == null) return "AR-15";
|
||||
String p = platform.trim();
|
||||
if (p.isEmpty()) return "AR-15";
|
||||
// normalize to AR-15 / AR-10 style
|
||||
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||
}
|
||||
|
||||
public record BuilderBootstrapDto(
|
||||
String platform,
|
||||
List<PartCategoryDto> categories,
|
||||
Map<String, String> partRoleMap,
|
||||
Map<String, List<String>> categoryRoles
|
||||
) {}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/imports")
|
||||
@RequestMapping("/api/admin/imports")
|
||||
@CrossOrigin(origins = "http://localhost:3000")
|
||||
public class ImportController {
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.stream.Collectors;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/merchant-category-mappings")
|
||||
@RequestMapping("/api/admin/merchant-category-mappings")
|
||||
@CrossOrigin
|
||||
public class MerchantCategoryMappingController {
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public class MerchantDebugController {
|
||||
this.merchantRepository = merchantRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/debug/merchants")
|
||||
@GetMapping("/api/admin/debug/merchants")
|
||||
public List<Merchant> listMerchants() {
|
||||
return merchantRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ public class ProductController {
|
||||
/**
|
||||
* List products for the builder, filterable by platform + partRoles.
|
||||
*
|
||||
* GET /api/products?platform=AR-15&partRoles=UPPER&partRoles=BARREL
|
||||
* Examples:
|
||||
* - GET /api/products?platform=AR-15
|
||||
* - GET /api/products?platform=AR-15&partRoles=upper-receiver&partRoles=upper
|
||||
* - GET /api/products?platform=ALL (no platform filter)
|
||||
* - GET /api/products?platform=ALL&partRoles=magazine
|
||||
*/
|
||||
@GetMapping
|
||||
@Cacheable(
|
||||
@@ -45,18 +49,27 @@ public class ProductController {
|
||||
@RequestParam(defaultValue = "AR-15") String platform,
|
||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||
) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
System.out.println("getProducts: start, platform=" + platform +
|
||||
", allPlatforms=" + allPlatforms +
|
||||
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
||||
|
||||
// 1) Load products (with brand pre-fetched)
|
||||
long tProductsStart = System.currentTimeMillis();
|
||||
List<Product> products;
|
||||
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
products = productRepository.findByPlatformWithBrand(platform);
|
||||
products = allPlatforms
|
||||
? productRepository.findAllWithBrand()
|
||||
: productRepository.findByPlatformWithBrand(platform);
|
||||
} else {
|
||||
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||
products = allPlatforms
|
||||
? productRepository.findByPartRoleInWithBrand(partRoles)
|
||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||
}
|
||||
|
||||
long tProductsEnd = System.currentTimeMillis();
|
||||
System.out.println("getProducts: loaded products: " +
|
||||
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
||||
@@ -75,6 +88,7 @@ public class ProductController {
|
||||
|
||||
List<ProductOffer> allOffers =
|
||||
productOfferRepository.findByProductIdIn(productIds);
|
||||
|
||||
long tOffersEnd = System.currentTimeMillis();
|
||||
System.out.println("getProducts: loaded offers: " +
|
||||
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
||||
@@ -97,6 +111,7 @@ public class ProductController {
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.toList();
|
||||
|
||||
long tMapEnd = System.currentTimeMillis();
|
||||
long took = System.currentTimeMillis() - started;
|
||||
|
||||
@@ -110,11 +125,6 @@ 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);
|
||||
@@ -135,9 +145,7 @@ public class ProductController {
|
||||
}
|
||||
|
||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||
if (offers == null || offers.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (offers == null || offers.isEmpty()) return null;
|
||||
|
||||
// Right now: lowest price wins, regardless of stock
|
||||
return offers.stream()
|
||||
@@ -146,11 +154,6 @@ public class ProductController {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -4,7 +4,6 @@ import group.goforward.battlbuilder.model.CategoryMapping;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartCategory;
|
||||
import group.goforward.battlbuilder.repos.CategoryMappingRepository;
|
||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
||||
import group.goforward.battlbuilder.repos.PartCategoryRepository;
|
||||
import group.goforward.battlbuilder.web.dto.admin.MerchantCategoryMappingDto;
|
||||
import group.goforward.battlbuilder.web.dto.admin.SimpleMerchantDto;
|
||||
@@ -17,20 +16,17 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/category-mappings")
|
||||
@CrossOrigin // you can tighten origins later
|
||||
@CrossOrigin // tighten later
|
||||
public class AdminCategoryMappingController {
|
||||
|
||||
private final CategoryMappingRepository categoryMappingRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final PartCategoryRepository partCategoryRepository;
|
||||
|
||||
public AdminCategoryMappingController(
|
||||
CategoryMappingRepository categoryMappingRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
PartCategoryRepository partCategoryRepository
|
||||
) {
|
||||
this.categoryMappingRepository = categoryMappingRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.partCategoryRepository = partCategoryRepository;
|
||||
}
|
||||
|
||||
@@ -59,7 +55,6 @@ public class AdminCategoryMappingController {
|
||||
if (merchantId != null) {
|
||||
mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId);
|
||||
} else {
|
||||
// fall back to all mappings; you can add a more specific repository method later if desired
|
||||
mappings = categoryMappingRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -77,10 +72,10 @@ public class AdminCategoryMappingController {
|
||||
|
||||
/**
|
||||
* Update a single mapping's part_category.
|
||||
* POST /api/admin/category-mappings/{id}
|
||||
* PUT /api/admin/category-mappings/{id}
|
||||
* Body: { "partCategoryId": 24 }
|
||||
*/
|
||||
@PostMapping("/{id}")
|
||||
@PutMapping("/{id}")
|
||||
public MerchantCategoryMappingDto updateMapping(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody UpdateMerchantCategoryMappingRequest request
|
||||
@@ -106,12 +101,4 @@ public class AdminCategoryMappingController {
|
||||
mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null
|
||||
);
|
||||
}
|
||||
@PutMapping("/{id}")
|
||||
public MerchantCategoryMappingDto updateMappingPut(
|
||||
@PathVariable Integer id,
|
||||
@RequestBody UpdateMerchantCategoryMappingRequest request
|
||||
) {
|
||||
// just delegate so POST & PUT behave the same
|
||||
return updateMapping(id, request);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import group.goforward.battlbuilder.web.dto.MerchantAdminDto;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/merchants")
|
||||
@RequestMapping("/api/admin/merchants")
|
||||
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
|
||||
public class MerchantAdminController {
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "part_role_category_mappings",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"}))
|
||||
public class PartRoleCategoryMapping {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
@Column(name = "platform", nullable = false)
|
||||
private String platform;
|
||||
|
||||
@Column(name = "part_role", nullable = false)
|
||||
private String partRole;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false)
|
||||
private PartCategory category;
|
||||
|
||||
@Column(name = "notes")
|
||||
private String notes;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
// getters/setters…
|
||||
|
||||
public Integer getId() { return id; }
|
||||
public void setId(Integer id) { this.id = id; }
|
||||
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
public String getPartRole() { return partRole; }
|
||||
public void setPartRole(String partRole) { this.partRole = partRole; }
|
||||
|
||||
public PartCategory getCategory() { return category; }
|
||||
public void setCategory(PartCategory category) { this.category = category; }
|
||||
|
||||
public String getNotes() { return notes; }
|
||||
public void setNotes(String notes) { this.notes = notes; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package group.goforward.battlbuilder.repos;
|
||||
|
||||
import group.goforward.battlbuilder.model.PartRoleCategoryMapping;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PartRoleCategoryMappingRepository extends JpaRepository<PartRoleCategoryMapping, Integer> {
|
||||
|
||||
List<PartRoleCategoryMapping> findAllByPlatformOrderByPartRoleAsc(String platform);
|
||||
|
||||
Optional<PartRoleCategoryMapping> findByPlatformAndPartRole(String platform, String partRole);
|
||||
}
|
||||
@@ -8,25 +8,15 @@ import java.util.Optional;
|
||||
|
||||
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
||||
|
||||
// For resolver: one mapping per platform + partRole
|
||||
Optional<PartRoleMapping> findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
|
||||
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by builder/bootstrap flows (case-insensitive)
|
||||
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||
|
||||
// Used by resolvers when mapping a single role (case-insensitive)
|
||||
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
||||
String platform,
|
||||
String partRole
|
||||
);
|
||||
|
||||
// Optional: debug / inspection
|
||||
List<PartRoleMapping> findAllByPlatformAndPartRoleAndDeletedAtIsNull(
|
||||
String platform,
|
||||
String partRole
|
||||
);
|
||||
|
||||
// This is the one PartRoleMappingService needs
|
||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(
|
||||
String platform
|
||||
);
|
||||
|
||||
List<PartRoleMapping> findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
|
||||
String platform,
|
||||
String slug
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package group.goforward.battlbuilder.repos;
|
||||
|
||||
import group.goforward.battlbuilder.model.PlatformRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
||||
|
||||
List<PlatformRule> findByActiveTrueOrderByPriorityDesc();
|
||||
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
||||
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||
}
|
||||
@@ -55,6 +55,23 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||
@Param("roles") List<String> roles
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT p
|
||||
FROM Product p
|
||||
JOIN FETCH p.brand b
|
||||
WHERE p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findAllWithBrand();
|
||||
|
||||
@Query("""
|
||||
SELECT p
|
||||
FROM Product p
|
||||
JOIN FETCH p.brand b
|
||||
WHERE p.partRole IN :roles
|
||||
AND p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findByPartRoleInWithBrand(@Param("roles") List<String> roles);
|
||||
|
||||
// -------------------------------------------------
|
||||
// Used by /api/gunbuilder/test-products-db
|
||||
// -------------------------------------------------
|
||||
@@ -210,5 +227,7 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||
and p.import_status = 'PENDING_MAPPING'
|
||||
and p.deleted_at is null
|
||||
""", nativeQuery = true)
|
||||
|
||||
|
||||
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,18 +21,15 @@ public class PartCategoryResolverService {
|
||||
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
||||
*/
|
||||
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
||||
if (platform == null || partRole == null) return Optional.empty();
|
||||
|
||||
if (platform == null || partRole == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String p = platform.trim();
|
||||
String r = partRole.trim();
|
||||
|
||||
// Keep things case-sensitive since your DB values are already uppercase.
|
||||
Optional<PartRoleMapping> mappingOpt =
|
||||
partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
|
||||
platform,
|
||||
partRole
|
||||
);
|
||||
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
||||
|
||||
return mappingOpt.map(PartRoleMapping::getPartCategory);
|
||||
return partRoleMappingRepository
|
||||
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
||||
.map(PartRoleMapping::getPartCategory);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package group.goforward.battlbuilder.services.impl;
|
||||
|
||||
import group.goforward.battlbuilder.classification.PartRoleResolver;
|
||||
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
@@ -40,8 +40,15 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
// Part role from mapping (if present), else rules, else infer
|
||||
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal)
|
||||
.orElseGet(() -> {
|
||||
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKeyFinal);
|
||||
return resolved != null ? resolved : inferPartRole(row);
|
||||
String resolved = partRoleResolver.resolve(
|
||||
platformFinal,
|
||||
row.productName(),
|
||||
rawCategoryKeyFinal
|
||||
);
|
||||
if (resolved != null && !resolved.isBlank()) return resolved;
|
||||
|
||||
// ✅ IMPORTANT: pass rawCategoryKey so inference can see "Complete Uppers" etc.
|
||||
return inferPartRole(row, rawCategoryKeyFinal);
|
||||
});
|
||||
|
||||
partRole = normalizePartRole(partRole);
|
||||
@@ -58,7 +65,7 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
);
|
||||
|
||||
return mappings.stream()
|
||||
.map(m -> m.getMappedPartRole())
|
||||
.map(MerchantCategoryMap::getMappedPartRole)
|
||||
.filter(r -> r != null && !r.isBlank())
|
||||
.findFirst();
|
||||
}
|
||||
@@ -84,37 +91,111 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
}
|
||||
|
||||
private String inferPlatform(MerchantFeedRow row) {
|
||||
String department = coalesce(
|
||||
trimOrNull(row.department()),
|
||||
trimOrNull(row.category())
|
||||
);
|
||||
if (department == null) return null;
|
||||
String blob = String.join(" ",
|
||||
coalesce(trimOrNull(row.department()), ""),
|
||||
coalesce(trimOrNull(row.category()), ""),
|
||||
coalesce(trimOrNull(row.subCategory()), "")
|
||||
).toLowerCase(Locale.ROOT);
|
||||
|
||||
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";
|
||||
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
||||
if (blob.contains("ar-10") || blob.contains("ar10")) return "AR-10";
|
||||
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||
|
||||
// default
|
||||
return "AR-15";
|
||||
return "AR-15"; // safe default
|
||||
}
|
||||
|
||||
private String inferPartRole(MerchantFeedRow row) {
|
||||
String cat = coalesce(
|
||||
trimOrNull(row.subCategory()),
|
||||
trimOrNull(row.category())
|
||||
/**
|
||||
* Fallback inference ONLY. Prefer:
|
||||
* 1) merchant mapping table
|
||||
* 2) PartRoleResolver rules
|
||||
* 3) this method
|
||||
*
|
||||
* Key principle: use rawCategoryKey + productName (not just subCategory),
|
||||
* because merchants often encode the important signal in category paths.
|
||||
*/
|
||||
private String inferPartRole(MerchantFeedRow row, String rawCategoryKey) {
|
||||
String subCat = trimOrNull(row.subCategory());
|
||||
String cat = trimOrNull(row.category());
|
||||
String dept = trimOrNull(row.department());
|
||||
String name = trimOrNull(row.productName());
|
||||
|
||||
// Combine ALL possible signals
|
||||
String combined = coalesce(
|
||||
rawCategoryKey,
|
||||
joinNonNull(" > ", dept, cat, subCat),
|
||||
cat,
|
||||
subCat
|
||||
);
|
||||
if (cat == null) return null;
|
||||
|
||||
String lower = cat.toLowerCase(Locale.ROOT);
|
||||
String combinedLower = combined == null ? "" : combined.toLowerCase(Locale.ROOT);
|
||||
String nameLower = name == null ? "" : name.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";
|
||||
// ---------- HIGH PRIORITY: COMPLETE ASSEMBLIES ----------
|
||||
// rawCategoryKey from your DB shows "Ar-15 Complete Uppers" — grab that first.
|
||||
boolean looksLikeCompleteUpper =
|
||||
combinedLower.contains("complete upper") ||
|
||||
combinedLower.contains("complete uppers") ||
|
||||
combinedLower.contains("upper receiver assembly") ||
|
||||
combinedLower.contains("barreled upper") ||
|
||||
nameLower.contains("complete upper") ||
|
||||
nameLower.contains("complete upper receiver") ||
|
||||
nameLower.contains("barreled upper") ||
|
||||
nameLower.contains("upper receiver assembly");
|
||||
|
||||
if (looksLikeCompleteUpper) return "complete-upper";
|
||||
|
||||
boolean looksLikeCompleteLower =
|
||||
combinedLower.contains("complete lower") ||
|
||||
combinedLower.contains("complete lowers") ||
|
||||
nameLower.contains("complete lower");
|
||||
|
||||
if (looksLikeCompleteLower) return "complete-lower";
|
||||
|
||||
// ---------- RECEIVERS ----------
|
||||
// If we see "stripped upper", prefer upper-receiver. Otherwise "upper" can be generic.
|
||||
boolean looksLikeStrippedUpper =
|
||||
combinedLower.contains("stripped upper") ||
|
||||
nameLower.contains("stripped upper");
|
||||
|
||||
if (looksLikeStrippedUpper) return "upper-receiver";
|
||||
|
||||
boolean looksLikeStrippedLower =
|
||||
combinedLower.contains("stripped lower") ||
|
||||
nameLower.contains("stripped lower");
|
||||
|
||||
if (looksLikeStrippedLower) return "lower-receiver";
|
||||
|
||||
// ---------- COMMON PARTS ----------
|
||||
if (combinedLower.contains("handguard") || combinedLower.contains("rail")) return "handguard";
|
||||
if (combinedLower.contains("barrel")) return "barrel";
|
||||
if (combinedLower.contains("gas block") || combinedLower.contains("gas-block") || combinedLower.contains("gasblock")) return "gas-block";
|
||||
if (combinedLower.contains("gas tube") || combinedLower.contains("gas-tube") || combinedLower.contains("gastube")) return "gas-tube";
|
||||
if (combinedLower.contains("muzzle") || combinedLower.contains("brake") || combinedLower.contains("compensator")) return "muzzle-device";
|
||||
if (combinedLower.contains("bolt carrier") || combinedLower.contains("bolt-carrier") || combinedLower.contains("bcg")) return "bcg";
|
||||
if (combinedLower.contains("charging handle") || combinedLower.contains("charging-handle")) return "charging-handle";
|
||||
|
||||
if (combinedLower.contains("lower parts") || combinedLower.contains("lower-parts") || combinedLower.contains("lpk")) return "lower-parts";
|
||||
if (combinedLower.contains("trigger")) return "trigger";
|
||||
if (combinedLower.contains("pistol grip") || combinedLower.contains("grip")) return "grip";
|
||||
if (combinedLower.contains("safety") || combinedLower.contains("selector")) return "safety";
|
||||
if (combinedLower.contains("buffer")) return "buffer";
|
||||
if (combinedLower.contains("stock") || combinedLower.contains("buttstock") || combinedLower.contains("brace")) return "stock";
|
||||
|
||||
if (combinedLower.contains("magazine") || combinedLower.contains(" mag ") || combinedLower.equals("mag")) return "magazine";
|
||||
if (combinedLower.contains("sight")) return "sights";
|
||||
if (combinedLower.contains("optic") || combinedLower.contains("scope")) return "optic";
|
||||
if (combinedLower.contains("suppress")) return "suppressor";
|
||||
if (combinedLower.contains("light") || combinedLower.contains("laser")) return "weapon-light";
|
||||
if (combinedLower.contains("bipod")) return "bipod";
|
||||
if (combinedLower.contains("sling")) return "sling";
|
||||
if (combinedLower.contains("foregrip") || combinedLower.contains("vertical grip") || combinedLower.contains("angled")) return "foregrip";
|
||||
if (combinedLower.contains("tool") || combinedLower.contains("wrench") || combinedLower.contains("armorer")) return "tools";
|
||||
|
||||
// ---------- LAST RESORT ----------
|
||||
// If it says "upper" but NOT complete upper, keep it generic
|
||||
if (combinedLower.contains("upper")) return "upper";
|
||||
if (combinedLower.contains("lower")) return "lower";
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -139,4 +220,15 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String joinNonNull(String sep, String... parts) {
|
||||
if (parts == null || parts.length == 0) return null;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String p : parts) {
|
||||
if (p == null || p.isBlank()) continue;
|
||||
if (!sb.isEmpty()) sb.append(sep);
|
||||
sb.append(p.trim());
|
||||
}
|
||||
return sb.isEmpty() ? null : sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
|
||||
// 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;
|
||||
@@ -13,8 +10,7 @@ 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.catalog.classification.PlatformResolver;
|
||||
import group.goforward.battlbuilder.catalog.classification.ProductContext;
|
||||
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
@@ -38,6 +34,13 @@ import java.util.*;
|
||||
*
|
||||
* - importMerchantFeed: full ETL (products + offers)
|
||||
* - syncOffersOnly: only refresh offers/prices/stock from an offers feed
|
||||
*
|
||||
* IMPORTANT:
|
||||
* Classification (platform + partRole + rawCategoryKey) must run through CategoryClassificationService
|
||||
* so we respect:
|
||||
* 1) merchant_category_mappings (admin UI mapping)
|
||||
* 2) rule-based resolver
|
||||
* 3) fallback inference
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -48,21 +51,21 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final BrandRepository brandRepository;
|
||||
private final ProductRepository productRepository;
|
||||
private final PlatformResolver platformResolver;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
private final CategoryClassificationService categoryClassificationService;
|
||||
|
||||
public MerchantFeedImportServiceImpl(
|
||||
MerchantRepository merchantRepository,
|
||||
BrandRepository brandRepository,
|
||||
ProductRepository productRepository,
|
||||
PlatformResolver platformResolver,
|
||||
ProductOfferRepository productOfferRepository
|
||||
ProductOfferRepository productOfferRepository,
|
||||
CategoryClassificationService categoryClassificationService
|
||||
) {
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.brandRepository = brandRepository;
|
||||
this.productRepository = productRepository;
|
||||
this.platformResolver = platformResolver;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
this.categoryClassificationService = categoryClassificationService;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -83,9 +86,15 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
for (MerchantFeedRow row : rows) {
|
||||
Brand brand = resolveBrand(row);
|
||||
Product p = upsertProduct(merchant, brand, row);
|
||||
|
||||
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
|
||||
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
||||
}
|
||||
|
||||
merchant.setLastFullImportAt(OffsetDateTime.now());
|
||||
merchantRepository.save(merchant);
|
||||
|
||||
log.info("Completed full import for merchantId={} ({} rows processed)", merchantId, rows.size());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -93,9 +102,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||
log.debug("Upserting product for brand={}, sku={}, name={}",
|
||||
brand.getName(), row.sku(), row.productName());
|
||||
|
||||
String mpn = trimOrNull(row.manufacturerId());
|
||||
String upc = trimOrNull(row.sku()); // placeholder until a real UPC column exists
|
||||
|
||||
@@ -120,6 +126,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||
}
|
||||
p = candidates.get(0);
|
||||
// keep brand stable (but ensure it's set)
|
||||
if (p.getBrand() == null) p.setBrand(brand);
|
||||
}
|
||||
|
||||
updateProductFromRow(p, merchant, row, isNew);
|
||||
@@ -135,6 +143,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
Merchant merchant,
|
||||
MerchantFeedRow row,
|
||||
boolean isNew) {
|
||||
|
||||
// ---------- NAME ----------
|
||||
String name = coalesce(
|
||||
trimOrNull(row.productName()),
|
||||
@@ -142,32 +151,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
trimOrNull(row.longDescription()),
|
||||
trimOrNull(row.sku())
|
||||
);
|
||||
if (name == null) {
|
||||
name = "Unknown Product";
|
||||
}
|
||||
if (name == null) name = "Unknown Product";
|
||||
p.setName(name);
|
||||
|
||||
// ---------- SLUG ----------
|
||||
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
||||
String baseForSlug = coalesce(
|
||||
trimOrNull(name),
|
||||
trimOrNull(row.sku())
|
||||
);
|
||||
if (baseForSlug == null) {
|
||||
baseForSlug = "product-" + System.currentTimeMillis();
|
||||
}
|
||||
String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku()));
|
||||
if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis();
|
||||
|
||||
String slug = baseForSlug
|
||||
.toLowerCase()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("(^-|-$)", "");
|
||||
|
||||
if (slug.isBlank()) {
|
||||
slug = "product-" + System.currentTimeMillis();
|
||||
}
|
||||
if (slug.isBlank()) slug = "product-" + System.currentTimeMillis();
|
||||
|
||||
String uniqueSlug = generateUniqueSlug(slug);
|
||||
p.setSlug(uniqueSlug);
|
||||
p.setSlug(generateUniqueSlug(slug));
|
||||
}
|
||||
|
||||
// ---------- DESCRIPTIONS ----------
|
||||
@@ -183,56 +182,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
p.setMainImageUrl(mainImage);
|
||||
|
||||
// ---------- IDENTIFIERS ----------
|
||||
String mpn = coalesce(
|
||||
trimOrNull(row.manufacturerId()),
|
||||
trimOrNull(row.sku())
|
||||
);
|
||||
String mpn = coalesce(trimOrNull(row.manufacturerId()), trimOrNull(row.sku()));
|
||||
p.setMpn(mpn);
|
||||
p.setUpc(null); // placeholder
|
||||
|
||||
// ---------- RAW CATEGORY KEY ----------
|
||||
String rawCategoryKey = buildRawCategoryKey(row);
|
||||
p.setRawCategoryKey(rawCategoryKey);
|
||||
// ---------- CLASSIFICATION (rawCategoryKey + platform + partRole) ----------
|
||||
CategoryClassificationService.Result r = categoryClassificationService.classify(merchant, row);
|
||||
|
||||
// ---------- PLATFORM (base heuristic + rule resolver) ----------
|
||||
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
||||
String basePlatform = inferPlatform(row);
|
||||
// Always persist the rawCategoryKey coming out of classification (consistent keying)
|
||||
p.setRawCategoryKey(r.rawCategoryKey());
|
||||
|
||||
Long merchantId = merchant.getId() != null
|
||||
? merchant.getId().longValue()
|
||||
: null;
|
||||
|
||||
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);
|
||||
// Respect platformLocked: if locked and platform already present, keep it.
|
||||
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked()) || p.getPlatform() == null || p.getPlatform().isBlank()) {
|
||||
String platform = (r.platform() == null || r.platform().isBlank()) ? "AR-15" : r.platform();
|
||||
p.setPlatform(platform);
|
||||
}
|
||||
|
||||
// ---------- PART ROLE (keyword-based for now) ----------
|
||||
String partRole = inferPartRole(row);
|
||||
if (partRole == null || partRole.isBlank()) {
|
||||
partRole = "UNKNOWN";
|
||||
} else {
|
||||
partRole = partRole.trim();
|
||||
}
|
||||
// Part role should always be driven by classification (mapping/rules/inference),
|
||||
// but if something returns null/blank, treat as unknown.
|
||||
String partRole = (r.partRole() == null) ? "unknown" : r.partRole().trim();
|
||||
if (partRole.isBlank()) partRole = "unknown";
|
||||
p.setPartRole(partRole);
|
||||
|
||||
// ---------- IMPORT STATUS ----------
|
||||
if ("UNKNOWN".equalsIgnoreCase(partRole)) {
|
||||
if ("unknown".equalsIgnoreCase(partRole) || "UNKNOWN".equalsIgnoreCase(partRole)) {
|
||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||
} else {
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
@@ -273,7 +246,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
||||
|
||||
BigDecimal retail = row.retailPrice();
|
||||
BigDecimal sale = row.salePrice();
|
||||
BigDecimal sale = row.salePrice();
|
||||
|
||||
BigDecimal effectivePrice;
|
||||
BigDecimal originalPrice;
|
||||
@@ -426,7 +399,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
||||
// 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");
|
||||
List<String> requiredHeaders = Collections.singletonList("SKU");
|
||||
|
||||
Exception lastException = null;
|
||||
|
||||
@@ -455,11 +428,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
.setIgnoreSurroundingSpaces(true)
|
||||
.setTrim(true)
|
||||
.build();
|
||||
} else if (headerMap != null) {
|
||||
log.debug("Delimiter '{}' produced headers {} for feed {}",
|
||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)),
|
||||
headerMap.keySet(),
|
||||
feedUrl);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
lastException = ex;
|
||||
@@ -467,9 +435,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (lastException != null) {
|
||||
log.debug("Last delimiter detection error:", lastException);
|
||||
}
|
||||
|
||||
return CSVFormat.DEFAULT.builder()
|
||||
.setDelimiter(',')
|
||||
.setHeader()
|
||||
@@ -569,12 +539,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
|
||||
private String getCsvValue(CSVRecord rec, String header) {
|
||||
if (rec == null || header == null) {
|
||||
return null;
|
||||
}
|
||||
if (!rec.isMapped(header)) {
|
||||
return null;
|
||||
}
|
||||
if (rec == null || header == null) return null;
|
||||
if (!rec.isMapped(header)) return null;
|
||||
|
||||
try {
|
||||
return rec.get(header);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
@@ -593,9 +560,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
private String coalesce(String... values) {
|
||||
if (values == null) return null;
|
||||
for (String v : values) {
|
||||
if (v != null && !v.isBlank()) {
|
||||
return v;
|
||||
}
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -609,70 +574,4 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||
String dept = trimOrNull(row.department());
|
||||
String cat = trimOrNull(row.category());
|
||||
String sub = trimOrNull(row.subCategory());
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (dept != null) parts.add(dept);
|
||||
if (cat != null) parts.add(cat);
|
||||
if (sub != null) parts.add(sub);
|
||||
|
||||
if (parts.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String.join(" > ", parts);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user