diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java index 6ec0061..6453e8f 100644 --- a/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java @@ -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) { diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java index e63b6f3..dddd39b 100644 --- a/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PlatformResolver.java @@ -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 compiledRules; + public static final String NOT_SUPPORTED = "NOT-SUPPORTED"; - public PlatformResolver(PlatformRuleRepository ruleRepository) { - this.ruleRepository = ruleRepository; + private final PlatformRuleRepository repo; + private final List rules = new ArrayList<>(); + + public PlatformResolver(PlatformRuleRepository repo) { + this.repo = repo; } @PostConstruct - public void loadRules() { - List 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 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; + } } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/BuilderBootstrapController.java b/src/main/java/group/goforward/battlbuilder/controllers/BuilderBootstrapController.java new file mode 100644 index 0000000..2071d1c --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/BuilderBootstrapController.java @@ -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 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 roleToCategorySlug = new LinkedHashMap<>(); + + List 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> categoryToRoles = new LinkedHashMap<>(); + for (Map.Entry 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 categories, + Map partRoleMap, + Map> categoryRoles + ) {} +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ImportController.java b/src/main/java/group/goforward/battlbuilder/controllers/ImportController.java index 44dc3d9..1ecfe76 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ImportController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ImportController.java @@ -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 { diff --git a/src/main/java/group/goforward/battlbuilder/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/battlbuilder/controllers/MerchantCategoryMappingController.java index fb97d20..20fc6f6 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/MerchantCategoryMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/MerchantCategoryMappingController.java @@ -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 { diff --git a/src/main/java/group/goforward/battlbuilder/controllers/MerchantDebugController.java b/src/main/java/group/goforward/battlbuilder/controllers/MerchantDebugController.java index bf3c41c..9077709 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/MerchantDebugController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/MerchantDebugController.java @@ -16,7 +16,7 @@ public class MerchantDebugController { this.merchantRepository = merchantRepository; } - @GetMapping("/admin/debug/merchants") + @GetMapping("/api/admin/debug/merchants") public List listMerchants() { return merchantRepository.findAll(); } diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java index 6fa6af8..7291c87 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductController.java @@ -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 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 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 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 getOffersForProduct(@PathVariable("id") Integer productId) { List offers = productOfferRepository.findByProductId(productId); @@ -135,9 +145,7 @@ public class ProductController { } private ProductOffer pickBestOffer(List 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 getProductById(@PathVariable("id") Integer productId) { return productRepository.findById(productId) diff --git a/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminCategoryMappingController.java b/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminCategoryMappingController.java index 36abf28..4e335ea 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminCategoryMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/admin/AdminCategoryMappingController.java @@ -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); - } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/admin/MerchantAdminController.java b/src/main/java/group/goforward/battlbuilder/controllers/admin/MerchantAdminController.java index dec23a5..fc9ed5d 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/admin/MerchantAdminController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/admin/MerchantAdminController.java @@ -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 { diff --git a/src/main/java/group/goforward/battlbuilder/model/PartRoleCategoryMapping.java b/src/main/java/group/goforward/battlbuilder/model/PartRoleCategoryMapping.java deleted file mode 100644 index 65d5bc4..0000000 --- a/src/main/java/group/goforward/battlbuilder/model/PartRoleCategoryMapping.java +++ /dev/null @@ -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; } -} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/PartRoleCategoryMappingRepository.java b/src/main/java/group/goforward/battlbuilder/repos/PartRoleCategoryMappingRepository.java deleted file mode 100644 index 09c36af..0000000 --- a/src/main/java/group/goforward/battlbuilder/repos/PartRoleCategoryMappingRepository.java +++ /dev/null @@ -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 { - - List findAllByPlatformOrderByPartRoleAsc(String platform); - - Optional findByPlatformAndPartRole(String platform, String partRole); -} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/PartRoleMappingRepository.java b/src/main/java/group/goforward/battlbuilder/repos/PartRoleMappingRepository.java index 4759f01..8bced23 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/PartRoleMappingRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/PartRoleMappingRepository.java @@ -8,25 +8,15 @@ import java.util.Optional; public interface PartRoleMappingRepository extends JpaRepository { - // For resolver: one mapping per platform + partRole - Optional findFirstByPlatformAndPartRoleAndDeletedAtIsNull( + // Used by admin screens / lists (case-sensitive, no platform normalization) + List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform); + + // Used by builder/bootstrap flows (case-insensitive) + List findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform); + + // Used by resolvers when mapping a single role (case-insensitive) + Optional findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull( String platform, String partRole ); - - // Optional: debug / inspection - List findAllByPlatformAndPartRoleAndDeletedAtIsNull( - String platform, - String partRole - ); - - // This is the one PartRoleMappingService needs - List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc( - String platform - ); - - List findByPlatformAndPartCategory_SlugAndDeletedAtIsNull( - String platform, - String slug - ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java b/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java index f753117..78d1538 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/PlatformRuleRepository.java @@ -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 { - List findByActiveTrueOrderByPriorityDesc(); + // Active rules, highest priority first (tie-breaker: id asc for stability) + List findAllByActiveTrueOrderByPriorityDescIdAsc(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index 9025108..0bef0f9 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -55,6 +55,23 @@ public interface ProductRepository extends JpaRepository { @Param("roles") List roles ); + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.deletedAt IS NULL + """) + List findAllWithBrand(); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.partRole IN :roles + AND p.deletedAt IS NULL + """) + List findByPartRoleInWithBrand(@Param("roles") List roles); + // ------------------------------------------------- // Used by /api/gunbuilder/test-products-db // ------------------------------------------------- @@ -210,5 +227,7 @@ public interface ProductRepository extends JpaRepository { and p.import_status = 'PENDING_MAPPING' and p.deleted_at is null """, nativeQuery = true) + + List findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); -} \ No newline at end of file +} diff --git a/src/main/java/group/goforward/battlbuilder/services/PartCategoryResolverService.java b/src/main/java/group/goforward/battlbuilder/services/PartCategoryResolverService.java index 62fbee7..58c0155 100644 --- a/src/main/java/group/goforward/battlbuilder/services/PartCategoryResolverService.java +++ b/src/main/java/group/goforward/battlbuilder/services/PartCategoryResolverService.java @@ -21,18 +21,15 @@ public class PartCategoryResolverService { * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower" */ public Optional 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 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); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java index d4457ca..58828fb 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java @@ -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(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java index 81591ab..c11963f 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java @@ -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 requiredHeaders = Arrays.asList("SKU"); + List 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 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"; - } } \ No newline at end of file