holy shit. fixed a lot. new rules engine driven by db. project runs

This commit is contained in:
2025-12-15 20:58:33 -05:00
parent 059aa7fb56
commit 1382f8c906
17 changed files with 451 additions and 348 deletions

View File

@@ -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) {

View File

@@ -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;
}
}
}

View File

@@ -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
) {}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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();
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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
);
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}