diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index 7b9a4f4..929e05d 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -19,6 +19,8 @@ import group.goforward.ballistic.model.Product; import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.repos.MerchantCategoryMapRepository; +import group.goforward.ballistic.model.MerchantCategoryMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,13 +31,16 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; + private final MerchantCategoryMapRepository merchantCategoryMapRepository; public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, BrandRepository brandRepository, - ProductRepository productRepository) { + ProductRepository productRepository, + MerchantCategoryMapRepository merchantCategoryMapRepository) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; + this.merchantCategoryMapRepository = merchantCategoryMapRepository; } @Override @@ -99,11 +104,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p = candidates.get(0); } - updateProductFromRow(p, row, isNew); + updateProductFromRow(p, merchant, row, isNew); return productRepository.save(p); } - private void updateProductFromRow(Product p, MerchantFeedRow row, boolean isNew) { + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- String name = coalesce( trimOrNull(row.productName()), @@ -164,14 +169,50 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService String platform = inferPlatform(row); p.setPlatform(platform != null ? platform : "AR-15"); + // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + // ---------- PART ROLE ---------- - String partRole = inferPartRole(row); + String partRole = resolvePartRole(merchant, row); if (partRole == null || partRole.isBlank()) { partRole = "unknown"; } p.setPartRole(partRole); } + private String resolvePartRole(Merchant merchant, MerchantFeedRow row) { + // Build a merchant-specific raw category key like "Department > Category > SubCategory" + String rawCategoryKey = buildRawCategoryKey(row); + + if (rawCategoryKey != null) { + MerchantCategoryMap mapping = merchantCategoryMapRepository + .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategoryKey) + .orElse(null); + + if (mapping != null && mapping.isEnabled()) { + String mappedPartRole = trimOrNull(mapping.getPartRole()); + if (mappedPartRole != null && !mappedPartRole.isBlank()) { + return mappedPartRole; + } + } + } + + // Fallback: keyword-based inference + String keywordRole = inferPartRole(row); + if (keywordRole != null && !keywordRole.isBlank()) { + return keywordRole; + } + + // Last resort: log as unmapped and return null/unknown + System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() + + ", rawCategoryKey='" + rawCategoryKey + "'" + + ", sku=" + row.sku() + + ", productName=" + row.productName()); + + return null; + } + // --------------------------------------------------------------------- // Feed reading + brand resolution // --------------------------------------------------------------------- @@ -293,6 +334,23 @@ 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()); + + java.util.List parts = new java.util.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; diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java new file mode 100644 index 0000000..b3bfa31 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java @@ -0,0 +1,86 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "merchant_category_map") +public class MerchantCategoryMap { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category", nullable = false, length = 255) + private String rawCategory; + + // NEW FIELDS + @Column(name = "platform") + private String platform; // e.g. "AR-15", "AR-10" + + @Column(name = "part_role") + private String partRole; // e.g. "barrel", "handguard" + + @Column(name = "canonical_category") + private String canonicalCategory; // e.g. "Rifle Barrels" + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + // --- getters & setters --- + + public Integer getId() { + return id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + 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 String getCanonicalCategory() { + return canonicalCategory; + } + + public void setCanonicalCategory(String canonicalCategory) { + this.canonicalCategory = canonicalCategory; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index ec584d9..f441762 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -55,6 +55,9 @@ public class Product { @Column(name = "deleted_at") private Instant deletedAt; + + @Column(name = "raw_category_key") + private String rawCategoryKey; // --- lifecycle hooks --- @@ -77,6 +80,14 @@ public class Product { updatedAt = Instant.now(); } + public String getRawCategoryKey() { + return rawCategoryKey; + } + + public void setRawCategoryKey(String rawCategoryKey) { + this.rawCategoryKey = rawCategoryKey; + } + // --- getters & setters --- public Integer getId() { diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java new file mode 100644 index 0000000..74781b8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java @@ -0,0 +1,12 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MerchantCategoryMapRepository extends JpaRepository { + + Optional findByMerchantAndRawCategoryIgnoreCase(Merchant merchant, String rawCategory); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java index dbef844..23baf5f 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java @@ -3,5 +3,9 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.Merchant; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MerchantRepository extends JpaRepository { + + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java b/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java new file mode 100644 index 0000000..a84f34b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java @@ -0,0 +1,179 @@ +package group.goforward.ballistic.seed; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMap; +import group.goforward.ballistic.repos.MerchantCategoryMapRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MerchantCategoryMapSeeder { + + @Bean + public CommandLineRunner seedMerchantCategoryMaps(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + return args -> { + // --- Guard: only seed if table is (mostly) empty --- + long existing = mapRepository.count(); + if (existing > 0) { + System.out.println("CategoryMapSeeder: found " + existing + " existing mappings, skipping seeding."); + return; + } + + System.out.println("CategoryMapSeeder: seeding initial MerchantCategoryMap rows..."); + + // Adjust merchant names if they differ in your DB + seedAeroPrecision(merchantRepository, mapRepository); + seedBrownells(merchantRepository, mapRepository); + seedPSA(merchantRepository, mapRepository); + + System.out.println("CategoryMapSeeder: seeding complete."); + }; + } + + // --------------------------------------------------------------------- + // AERO PRECISION + // --------------------------------------------------------------------- + + private void seedAeroPrecision(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Aero Precision").ifPresent(merchant -> { + + // Keys come from Department | Category | SubCategory combos + upsert(merchant, "Charging Handles", + "AR-15", "charging-handle", "Charging Handles", true, mapRepository); + + upsert(merchant, "Shop All Barrels", + null, "barrel", "Rifle Barrels", true, mapRepository); + + upsert(merchant, "Lower Parts Kits", + "AR-15", "lower-parts-kit", "Lower Parts Kits", true, mapRepository); + + upsert(merchant, "Handguards", + "AR-15", "handguard", "Handguards & Rails", true, mapRepository); + + upsert(merchant, "Upper Receivers", + "AR-15", "upper-receiver", "Upper Receivers", true, mapRepository); + + // Platform-only hints (let your existing heuristics decide part_role) + upsert(merchant, ".308 Winchester", + "AR-10", null, "AR-10 / .308 Parts", true, mapRepository); + + upsert(merchant, "6.5 Creedmoor", + "AR-10", null, "6.5 Creedmoor Parts", true, mapRepository); + + upsert(merchant, "5.56 Nato / .223 Wylde", + "AR-15", null, "5.56 / .223 Wylde Parts", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // BROWNELLS + // --------------------------------------------------------------------- + + private void seedBrownells(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Brownells").ifPresent(merchant -> { + + upsert(merchant, "Rifle Parts | Receiver Parts | Receivers", + null, "receiver", "Rifle Receivers", true, mapRepository); + + upsert(merchant, "Rifle Parts | Barrel Parts | Rifle Barrels", + null, "barrel", "Rifle Barrels", true, mapRepository); + + upsert(merchant, "Rifle Parts | Stock Parts | Rifle Stocks", + null, "stock", "Rifle Stocks", true, mapRepository); + + upsert(merchant, "Rifle Parts | Muzzle Devices | Compensators & Muzzle Brakes", + null, "muzzle-device", "Muzzle Devices", true, mapRepository); + + upsert(merchant, "Rifle Parts | Trigger Parts | Triggers", + null, "trigger", "Triggers", true, mapRepository); + + upsert(merchant, "Rifle Parts | Receiver Parts | Magazine Parts", + null, "magazine", "Magazine & Mag Parts", true, mapRepository); + + upsert(merchant, "Rifle Parts | Sights | Front Sights", + null, "sight", "Iron Sights", true, mapRepository); + + upsert(merchant, "Rifle Parts | Sights | Rear Sights", + null, "sight", "Iron Sights", true, mapRepository); + + upsert(merchant, "Rifle Parts | Receiver Parts | Buffer Tube Parts", + null, "buffer-tube", "Buffer Tubes & Parts", true, mapRepository); + + upsert(merchant, "Rifle Parts | Stock Parts | Buttstocks", + null, "stock", "Buttstocks", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // PALMETTO STATE ARMORY (PSA) + // --------------------------------------------------------------------- + + private void seedPSA(MerchantRepository merchantRepository, + MerchantCategoryMapRepository mapRepository) { + + merchantRepository.findByNameIgnoreCase("Palmetto State Armory").ifPresent(merchant -> { + + upsert(merchant, "AR-15 Parts | Upper Parts | Stripped Uppers", + "AR-15", "upper-receiver", "AR-15 Stripped Uppers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Upper Parts | Complete Uppers", + "AR-15", "complete-upper", "AR-15 Complete Uppers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Barrel Parts | Barrels", + "AR-15", "barrel", "AR-15 Barrels", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Lower Parts | Stripped Lowers", + "AR-15", "lower-receiver", "AR-15 Stripped Lowers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Handguard Parts | Handguards", + "AR-15", "handguard", "AR-15 Handguards", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Bolt Carrier Groups | Bolt Carrier Groups", + "AR-15", "bcg", "AR-15 BCGs", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Trigger Parts | Triggers", + "AR-15", "trigger", "AR-15 Triggers", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Stock Parts | Stocks", + "AR-15", "stock", "AR-15 Stocks", true, mapRepository); + + upsert(merchant, "AR-15 Parts | Muzzle Devices | Muzzle Devices", + "AR-15", "muzzle-device", "AR-15 Muzzle Devices", true, mapRepository); + }); + } + + // --------------------------------------------------------------------- + // Helper + // --------------------------------------------------------------------- + + private void upsert(Merchant merchant, + String rawCategory, + String platform, + String partRole, + String canonicalCategory, + boolean enabled, + MerchantCategoryMapRepository mapRepository) { + + MerchantCategoryMap map = mapRepository + .findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategory) + .orElseGet(MerchantCategoryMap::new); + + map.setMerchant(merchant); + map.setRawCategory(rawCategory); + + // These fields are optional – null means “let heuristics or defaults handle it” + map.setPlatform(platform); + map.setPartRole(partRole); + map.setCanonicalCategory(canonicalCategory); + map.setEnabled(enabled); + + mapRepository.save(map); + } +} \ No newline at end of file