diff --git a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java new file mode 100644 index 0000000..af9de5e --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java @@ -0,0 +1,24 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import java.util.List; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin // adjust later if you want +public class AdminMerchantController { + + private final MerchantRepository merchantRepository; + + public AdminMerchantController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List getMerchants() { + // If you want a DTO here, you can wrap it, but this is fine for internal admin + return merchantRepository.findAll(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java new file mode 100644 index 0000000..681eabf --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java @@ -0,0 +1,65 @@ +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; +import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchant-category-mappings") +@CrossOrigin +public class MerchantCategoryMappingController { + + private final MerchantCategoryMappingService mappingService; + private final MerchantRepository merchantRepository; + + public MerchantCategoryMappingController( + MerchantCategoryMappingService mappingService, + MerchantRepository merchantRepository + ) { + this.mappingService = mappingService; + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMappings( + @RequestParam("merchantId") Integer merchantId + ) { + List mappings = mappingService.findByMerchant(merchantId); + return mappings.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @PostMapping + public MerchantCategoryMappingDto upsertMapping( + @RequestBody UpsertMerchantCategoryMappingRequest request + ) { + Merchant merchant = merchantRepository + .findById(request.getMerchantId()) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); + + MerchantCategoryMapping mapping = mappingService.upsertMapping( + merchant, + request.getRawCategory(), + request.getMappedPartRole() + ); + + return toDto(mapping); + } + + private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { + MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); + dto.setId(mapping.getId()); + dto.setMerchantId(mapping.getMerchant().getId()); + dto.setMerchantName(mapping.getMerchant().getName()); + dto.setRawCategory(mapping.getRawCategory()); + dto.setMappedPartRole(mapping.getMappedPartRole()); + return dto; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java index c40033f..4e9b97c 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedImportServiceImpl.java @@ -19,8 +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 group.goforward.ballistic.service.MerchantCategoryMappingService; +import group.goforward.ballistic.service.MerchantCategoryMappingService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import group.goforward.ballistic.repos.ProductOfferRepository; @@ -35,18 +35,18 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; - private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final MerchantCategoryMappingService merchantCategoryMappingService; private final ProductOfferRepository productOfferRepository; public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, BrandRepository brandRepository, ProductRepository productRepository, - MerchantCategoryMapRepository merchantCategoryMapRepository, + MerchantCategoryMappingService merchantCategoryMappingService, ProductOfferRepository productOfferRepository) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; - this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.merchantCategoryMappingService = merchantCategoryMappingService; this.productOfferRepository = productOfferRepository; } @@ -180,8 +180,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setUpc(null); // ---------- PLATFORM ---------- - String platform = inferPlatform(row); - p.setPlatform(platform != null ? platform : "AR-15"); + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + } // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- String rawCategoryKey = buildRawCategoryKey(row); @@ -203,51 +205,60 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService System.out.println("IMPORT !!! skipping offer: no SKU for product id=" + product.getId()); return; } - - // Simple approach: always create a new offer row. - // (If you want idempotent imports later, we can add a repository finder - // like findByProductAndMerchantAndAvantlinkProductId(...) and reuse the row.) - ProductOffer offer = new ProductOffer(); - offer.setProduct(product); - offer.setMerchant(merchant); - offer.setAvantlinkProductId(avantlinkProductId); - + + // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElseGet(ProductOffer::new); + + // If this is a brand‑new offer, initialize key fields + if (offer.getId() == null) { + offer.setMerchant(merchant); + offer.setProduct(product); + offer.setAvantlinkProductId(avantlinkProductId); + offer.setFirstSeenAt(OffsetDateTime.now()); + } else { + // Make sure associations stay in sync if anything changed + offer.setMerchant(merchant); + offer.setProduct(product); + } + // Identifiers offer.setSku(trimOrNull(row.sku())); // No real UPC in this feed yet – leave null for now offer.setUpc(null); - + // Buy URL offer.setBuyUrl(trimOrNull(row.buyLink())); - + // Prices from feed BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant BigDecimal sale = row.salePrice(); - + BigDecimal effectivePrice; BigDecimal originalPrice; - - if (sale != null) { + + // Prefer sale price if it exists and is less than or equal to retail + if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { effectivePrice = sale; originalPrice = (retail != null ? retail : sale); } else { - effectivePrice = retail; - originalPrice = retail; + // Otherwise fall back to retail or whatever is present + effectivePrice = (retail != null ? retail : sale); + originalPrice = (retail != null ? retail : sale); } - + offer.setPrice(effectivePrice); offer.setOriginalPrice(originalPrice); - + // Currency + stock offer.setCurrency("USD"); // We don't have a real stock flag in this CSV, so assume in-stock for now offer.setInStock(Boolean.TRUE); - - // Timestamps - OffsetDateTime now = OffsetDateTime.now(); - offer.setLastSeenAt(now); - offer.setFirstSeenAt(now); // first import: treat now as first seen - + + // Update "last seen" on every import pass + offer.setLastSeenAt(OffsetDateTime.now()); + productOfferRepository.save(offer); } @@ -256,15 +267,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService 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; - } + // Delegate to the mapping service, which will: + // - Look up an existing mapping + // - If none exists, create a placeholder row with null mappedPartRole + // - Return the mapped partRole, or null if not yet mapped + String mapped = merchantCategoryMappingService.resolvePartRole(merchant, rawCategoryKey); + if (mapped != null && !mapped.isBlank()) { + return mapped; } } @@ -274,7 +283,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return keywordRole; } - // Last resort: log as unmapped and return null/unknown + // Last resort: log as unmapped and return null System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() + ", rawCategoryKey='" + rawCategoryKey + "'" + ", sku=" + row.sku() diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java deleted file mode 100644 index b3bfa31..0000000 --- a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMap.java +++ /dev/null @@ -1,86 +0,0 @@ -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/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java new file mode 100644 index 0000000..d90e561 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java @@ -0,0 +1,91 @@ +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "merchant_category_mappings", + uniqueConstraints = @UniqueConstraint( + name = "uq_merchant_category", + columnNames = { "merchant_id", "raw_category" } + ) +) +public class MerchantCategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "id", nullable = false) + 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 = 512) + private String rawCategory; + + @Column(name = "mapped_part_role", length = 128) + private String mappedPartRole; // e.g. "upper-receiver", "barrel" + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PreUpdate + public void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // getters & setters + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = 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 getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } + + 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/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java index f441762..0b0540e 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -59,6 +59,11 @@ public class Product { @Column(name = "raw_category_key") private String rawCategoryKey; + @Column(name = "platform_locked", nullable = false) + private Boolean platformLocked = false; + + + // --- lifecycle hooks --- @PrePersist @@ -209,4 +214,13 @@ public class Product { public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } + + public Boolean getPlatformLocked() { + return platformLocked; + } + + public void setPlatformLocked(Boolean platformLocked) { + this.platformLocked = platformLocked; + } + } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java deleted file mode 100644 index 74781b8..0000000 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMapRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -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/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java new file mode 100644 index 0000000..bddeed5 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -0,0 +1,17 @@ +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.MerchantCategoryMapping; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MerchantCategoryMappingRepository + extends JpaRepository { + + Optional findByMerchantIdAndRawCategoryIgnoreCase( + Integer merchantId, + String rawCategory + ); + + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java index cf4ff64..caaa372 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface ProductOfferRepository extends JpaRepository { @@ -12,4 +13,10 @@ public interface ProductOfferRepository extends JpaRepository findByProductIdIn(Collection productIds); + + // Unique offer lookup for importer upsert + Optional findByMerchantIdAndAvantlinkProductId( + Integer merchantId, + String avantlinkProductId + ); } \ 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 deleted file mode 100644 index a84f34b..0000000 --- a/src/main/java/group/goforward/ballistic/seed/MerchantCategoryMapSeeder.java +++ /dev/null @@ -1,179 +0,0 @@ -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 diff --git a/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java new file mode 100644 index 0000000..89df3f0 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/service/MerchantCategoryMappingService.java @@ -0,0 +1,77 @@ +package group.goforward.ballistic.service; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class MerchantCategoryMappingService { + + private final MerchantCategoryMappingRepository mappingRepository; + + public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { + this.mappingRepository = mappingRepository; + } + + public List findByMerchant(Integer merchantId) { + return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); + } + + /** + * Resolve a partRole for a given raw category. + * If not found, create a row with null mappedPartRole and return null (so importer can skip). + */ + @Transactional + public String resolvePartRole(Merchant merchant, String rawCategory) { + if (rawCategory == null || rawCategory.isBlank()) { + return null; + } + + String trimmed = rawCategory.trim(); + + Optional existingOpt = + mappingRepository.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed); + + if (existingOpt.isPresent()) { + return existingOpt.get().getMappedPartRole(); + } + + // Create placeholder row + MerchantCategoryMapping mapping = new MerchantCategoryMapping(); + mapping.setMerchant(merchant); + mapping.setRawCategory(trimmed); + mapping.setMappedPartRole(null); + + mappingRepository.save(mapping); + + // No mapping yet → importer should skip this product + return null; + } + + /** + * Upsert mapping (admin UI). + */ + @Transactional + public MerchantCategoryMapping upsertMapping(Merchant merchant, String rawCategory, String mappedPartRole) { + String trimmed = rawCategory.trim(); + + MerchantCategoryMapping mapping = mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(trimmed); + return m; + }); + + mapping.setMappedPartRole( + (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() + ); + + return mappingRepository.save(mapping); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java new file mode 100644 index 0000000..bb7a703 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java @@ -0,0 +1,50 @@ +package group.goforward.ballistic.web.dto; + +public class MerchantCategoryMappingDto { + + private Integer id; + private Integer merchantId; + private String merchantName; + private String rawCategory; + private String mappedPartRole; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java new file mode 100644 index 0000000..f0d102a --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java @@ -0,0 +1,32 @@ +package group.goforward.ballistic.web.dto; + +public class UpsertMerchantCategoryMappingRequest { + + private Integer merchantId; + private String rawCategory; + private String mappedPartRole; // can be null to "unmap" + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } +} \ No newline at end of file