From 58a6c671acda545e9a9fad0e622619c790a78bd7 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 7 Dec 2025 09:58:30 -0500 Subject: [PATCH] updating category/role mapping. new endpoints for unmapped parts and apis for front end --- .../ballistic/model/ImportStatus.java | 7 + .../goforward/ballistic/model/Product.java | 259 ++++------- .../MerchantCategoryMappingRepository.java | 11 + .../repos/ProductOfferRepository.java | 12 + .../ballistic/repos/ProductRepository.java | 95 +++- .../CategoryMappingRecommendationService.java | 72 +++ .../services/GunbuilderProductService.java | 7 +- .../services/ImportStatusAdminService.java | 42 ++ .../services/MappingAdminService.java | 83 ++++ .../MerchantCategoryMappingService.java | 1 - .../impl/MerchantFeedImportServiceImpl.java | 416 +++++++++--------- .../admin/CategoryMappingAdminController.java | 40 ++ .../admin/ImportStatusAdminController.java | 32 ++ .../dto/CategoryMappingRecommendationDto.java | 10 + .../web/dto/ImportStatusByMerchantDto.java | 11 + .../web/dto/ImportStatusSummaryDto.java | 9 + .../web/dto/PendingMappingBucketDto.java | 9 + .../web/dto/admin/AdminMappingController.java | 35 ++ 18 files changed, 764 insertions(+), 387 deletions(-) create mode 100644 src/main/java/group/goforward/ballistic/model/ImportStatus.java create mode 100644 src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java create mode 100644 src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java create mode 100644 src/main/java/group/goforward/ballistic/services/MappingAdminService.java create mode 100644 src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java create mode 100644 src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/AdminMappingController.java diff --git a/src/main/java/group/goforward/ballistic/model/ImportStatus.java b/src/main/java/group/goforward/ballistic/model/ImportStatus.java new file mode 100644 index 0000000..d4a6b64 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/model/ImportStatus.java @@ -0,0 +1,7 @@ +package group.goforward.ballistic.model; + +public enum ImportStatus { + PENDING_MAPPING, // Ingested but not fully mapped / trusted + MAPPED, // Clean + mapped + safe for builder + REJECTED // Junk / not relevant / explicitly excluded +} \ 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 816099f..8e482ae 100644 --- a/src/main/java/group/goforward/ballistic/model/Product.java +++ b/src/main/java/group/goforward/ballistic/model/Product.java @@ -9,22 +9,19 @@ import java.util.Objects; import java.util.Set; import java.util.HashSet; -import group.goforward.ballistic.model.ProductOffer; -import group.goforward.ballistic.model.ProductConfiguration; - @Entity @Table(name = "products") @NamedQuery(name="Products.findByPlatformWithBrand", query= "" + - "SELECT p FROM Product p" + - " JOIN FETCH p.brand b" + - " WHERE p.platform = :platform" + - " AND p.deletedAt IS NULL") + "SELECT p FROM Product p" + + " JOIN FETCH p.brand b" + + " WHERE p.platform = :platform" + + " AND p.deletedAt IS NULL") @NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" + - "SELECT p FROM Product p JOIN FETCH p.brand b" + - " WHERE p.platform = :platform" + - " AND p.partRole IN :roles" + - " AND p.deletedAt IS NULL") + "SELECT p FROM Product p JOIN FETCH p.brand b" + + " WHERE p.platform = :platform" + + " AND p.partRole IN :roles" + + " AND p.deletedAt IS NULL") @NamedQuery(name="Product.findProductsbyBrandByOffers", query="" + " SELECT DISTINCT p FROM Product p" + @@ -32,11 +29,10 @@ import group.goforward.ballistic.model.ProductConfiguration; " LEFT JOIN FETCH p.offers o" + " WHERE p.platform = :platform" + " AND p.deletedAt IS NULL") - public class Product { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres + @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) private Integer id; @@ -86,38 +82,27 @@ public class Product { @Column(name = "deleted_at") private Instant deletedAt; - + @Column(name = "raw_category_key") private String rawCategoryKey; @Column(name = "platform_locked", nullable = false) private Boolean platformLocked = false; + @Enumerated(EnumType.STRING) + @Column(name = "import_status", nullable = false) + private ImportStatus importStatus = ImportStatus.MAPPED; + @OneToMany(mappedBy = "product", fetch = FetchType.LAZY) private Set offers = new HashSet<>(); - public Set getOffers() { - return offers; - } - - public void setOffers(Set offers) { - this.offers = offers; - } - // --- lifecycle hooks --- - @PrePersist public void prePersist() { - if (uuid == null) { - uuid = UUID.randomUUID(); - } + if (uuid == null) uuid = UUID.randomUUID(); Instant now = Instant.now(); - if (createdAt == null) { - createdAt = now; - } - if (updatedAt == null) { - updatedAt = now; - } + if (createdAt == null) createdAt = now; + if (updatedAt == null) updatedAt = now; } @PreUpdate @@ -125,181 +110,101 @@ public class Product { updatedAt = Instant.now(); } - public String getRawCategoryKey() { - return rawCategoryKey; - } - - public void setRawCategoryKey(String rawCategoryKey) { - this.rawCategoryKey = rawCategoryKey; - } - // --- getters & setters --- + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public UUID getUuid() { - return uuid; - } - - public void setUuid(UUID uuid) { - this.uuid = uuid; - } - - public Brand getBrand() { - return brand; - } - - public void setBrand(Brand brand) { - this.brand = brand; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getSlug() { - return slug; - } - - public void setSlug(String slug) { - this.slug = slug; - } - - public String getMpn() { - return mpn; - } - - public void setMpn(String mpn) { - this.mpn = mpn; - } - - public String getUpc() { - return upc; - } - - public void setUpc(String upc) { - this.upc = upc; - } - - 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 getShortDescription() { - return shortDescription; + public UUID getUuid() { return uuid; } + public void setUuid(UUID uuid) { this.uuid = uuid; } + + public Brand getBrand() { return brand; } + public void setBrand(Brand brand) { this.brand = brand; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getMpn() { return mpn; } + public void setMpn(String mpn) { this.mpn = mpn; } + + public String getUpc() { return upc; } + public void setUpc(String upc) { this.upc = upc; } + + 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 ProductConfiguration getConfiguration() { return configuration; } + public void setConfiguration(ProductConfiguration configuration) { + this.configuration = configuration; } + public String getShortDescription() { return shortDescription; } public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; } - public String getDescription() { - return description; - } - + public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } - public String getMainImageUrl() { - return mainImageUrl; - } - + public String getMainImageUrl() { return mainImageUrl; } public void setMainImageUrl(String mainImageUrl) { this.mainImageUrl = mainImageUrl; } - public Instant getCreatedAt() { - return createdAt; - } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt; - } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } - public Instant getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Instant updatedAt) { - this.updatedAt = updatedAt; - } - - public Instant getDeletedAt() { - return deletedAt; - } - - public void setDeletedAt(Instant deletedAt) { - this.deletedAt = deletedAt; - } - - public Boolean getPlatformLocked() { - return platformLocked; - } + public Instant getDeletedAt() { return deletedAt; } + public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; } + public Boolean getPlatformLocked() { return platformLocked; } public void setPlatformLocked(Boolean platformLocked) { this.platformLocked = platformLocked; } - - public ProductConfiguration getConfiguration() { - return configuration; + + public String getRawCategoryKey() { return rawCategoryKey; } + public void setRawCategoryKey(String rawCategoryKey) { + this.rawCategoryKey = rawCategoryKey; } - public void setConfiguration(ProductConfiguration configuration) { - this.configuration = configuration; - } - // Convenience: best offer price for Gunbuilder -public BigDecimal getBestOfferPrice() { - if (offers == null || offers.isEmpty()) { - return BigDecimal.ZERO; + public ImportStatus getImportStatus() { return importStatus; } + public void setImportStatus(ImportStatus importStatus) { + this.importStatus = importStatus; } - return offers.stream() - // pick sale_price if present, otherwise retail_price - .map(offer -> { - if (offer.getSalePrice() != null) { - return offer.getSalePrice(); - } - return offer.getRetailPrice(); - }) - .filter(Objects::nonNull) - .min(BigDecimal::compareTo) - .orElse(BigDecimal.ZERO); -} + public Set getOffers() { return offers; } + public void setOffers(Set offers) { this.offers = offers; } + + // --- computed helpers --- + + public BigDecimal getBestOfferPrice() { + if (offers == null || offers.isEmpty()) return BigDecimal.ZERO; + + return offers.stream() + .map(offer -> offer.getSalePrice() != null + ? offer.getSalePrice() + : offer.getRetailPrice()) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } - // Convenience: URL for the best-priced offer public String getBestOfferBuyUrl() { - if (offers == null || offers.isEmpty()) { - return null; - } + if (offers == null || offers.isEmpty()) return null; return offers.stream() .sorted(Comparator.comparing(offer -> { - if (offer.getSalePrice() != null) { - return offer.getSalePrice(); - } + if (offer.getSalePrice() != null) return offer.getSalePrice(); return offer.getRetailPrice(); }, Comparator.nullsLast(BigDecimal::compareTo))) .map(ProductOffer::getAffiliateUrl) @@ -307,4 +212,4 @@ public BigDecimal getBestOfferPrice() { .findFirst() .orElse(null); } -} +} \ 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 index f26eca3..6a4de92 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -1,5 +1,6 @@ package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.MerchantCategoryMapping; import java.util.List; import java.util.Optional; @@ -13,5 +14,15 @@ public interface MerchantCategoryMappingRepository String rawCategory ); + Optional findByMerchantIdAndRawCategory( + Integer merchantId, + String rawCategory + ); + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); + + Optional findByMerchantAndRawCategoryIgnoreCase( + Merchant merchant, + String rawCategory + ); } \ 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 6178413..bf59f18 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -2,6 +2,7 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.ProductOffer; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Collection; import java.util.List; @@ -19,4 +20,15 @@ public interface ProductOfferRepository extends JpaRepository countByMerchantPlatformAndStatus(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index 1801d49..bd20f6b 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,6 +1,7 @@ package group.goforward.ballistic.repos; import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.ImportStatus; import group.goforward.ballistic.model.Product; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -8,6 +9,7 @@ import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; +import java.util.Map; public interface ProductRepository extends JpaRepository { @@ -65,7 +67,8 @@ public interface ProductRepository extends JpaRepository { List findTop5ByPlatformWithBrand(@Param("platform") String platform); // ------------------------------------------------- - // Used by GunbuilderProductService + // Used by GunbuilderProductService (builder UI) + // Only returns MAPPED products // ------------------------------------------------- @Query(""" @@ -75,10 +78,98 @@ public interface ProductRepository extends JpaRepository { LEFT JOIN FETCH p.offers o WHERE p.platform = :platform AND p.partRole IN :partRoles + AND p.importStatus = :status AND p.deletedAt IS NULL """) List findForGunbuilderByPlatformAndPartRoles( @Param("platform") String platform, - @Param("partRoles") Collection partRoles + @Param("partRoles") Collection partRoles, + @Param("status") ImportStatus status + ); + + // ------------------------------------------------- + // Admin import-status dashboard (summary) + // ------------------------------------------------- + @Query(""" + SELECT p.importStatus AS status, COUNT(p) AS count + FROM Product p + WHERE p.deletedAt IS NULL + GROUP BY p.importStatus + """) + List> aggregateByImportStatus(); + + // ------------------------------------------------- + // Admin import-status dashboard (by merchant) + // ------------------------------------------------- + @Query(""" + SELECT m.name AS merchantName, + p.platform AS platform, + p.importStatus AS status, + COUNT(p) AS count + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + GROUP BY m.name, p.platform, p.importStatus + """) + List> aggregateByMerchantAndStatus(); + + // ------------------------------------------------- + // Admin: Unmapped category clusters + // ------------------------------------------------- + @Query(""" + SELECT p.rawCategoryKey AS rawCategoryKey, + m.name AS merchantName, + COUNT(DISTINCT p.id) AS productCount + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND (p.importStatus = group.goforward.ballistic.model.ImportStatus.PENDING_MAPPING + OR p.partRole IS NULL + OR LOWER(p.partRole) = 'unknown') + AND p.rawCategoryKey IS NOT NULL + GROUP BY p.rawCategoryKey, m.name + ORDER BY productCount DESC + """) + List> findUnmappedCategoryGroups(); + + @Query(""" + SELECT p + FROM Product p + JOIN p.offers o + JOIN o.merchant m + WHERE p.deletedAt IS NULL + AND m.name = :merchantName + AND p.rawCategoryKey = :rawCategoryKey + ORDER BY p.id + """) + List findExamplesForCategoryGroup( + @Param("merchantName") String merchantName, + @Param("rawCategoryKey") String rawCategoryKey + ); + + // ------------------------------------------------- + // Admin Bulk Mapper: Merchant + rawCategoryKey buckets + // ------------------------------------------------- + @Query(""" + SELECT m.id, + m.name, + p.rawCategoryKey, + COALESCE(mcm.mappedPartRole, '') AS mappedPartRole, + COUNT(DISTINCT p.id) + FROM Product p + JOIN p.offers o + JOIN o.merchant m + LEFT JOIN MerchantCategoryMapping mcm + ON mcm.merchant = m + AND mcm.rawCategory = p.rawCategoryKey + WHERE p.importStatus = :status + AND p.deletedAt IS NULL + GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole + ORDER BY COUNT(DISTINCT p.id) DESC + """) + List findPendingMappingBuckets( + @Param("status") ImportStatus status ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java b/src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java new file mode 100644 index 0000000..92b1713 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java @@ -0,0 +1,72 @@ +// src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.CategoryMappingRecommendationDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class CategoryMappingRecommendationService { + + private final ProductRepository productRepository; + + public CategoryMappingRecommendationService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List listRecommendations() { + var groups = productRepository.findUnmappedCategoryGroups(); + + return groups.stream() + .map(row -> { + String merchantName = (String) row.get("merchantName"); + String rawCategoryKey = (String) row.get("rawCategoryKey"); + long count = (long) row.get("productCount"); + + // Pull one sample product name + List examples = productRepository + .findExamplesForCategoryGroup(merchantName, rawCategoryKey); + String sampleName = examples.isEmpty() + ? null + : examples.get(0).getName(); + + String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName); + + return new CategoryMappingRecommendationDto( + merchantName, + rawCategoryKey, + count, + recommendedRole, + sampleName + ); + }) + .toList(); + } + + private String inferPartRoleFromRawKey(String rawKey, String sampleName) { + String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase(); + + if (blob.contains("handguard") || blob.contains("rail")) return "handguard"; + if (blob.contains("barrel")) return "barrel"; + if (blob.contains("upper")) return "upper-receiver"; + if (blob.contains("lower")) return "lower-receiver"; + if (blob.contains("mag") || blob.contains("magazine")) return "magazine"; + if (blob.contains("stock") || blob.contains("buttstock")) return "stock"; + if (blob.contains("grip")) return "grip"; + if (blob.contains("trigger")) return "trigger"; + if (blob.contains("sight") || blob.contains("iron sights")) return "sights"; + if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic"; + if (blob.contains("buffer")) return "buffer"; + if (blob.contains("gas block")) return "gas-block"; + if (blob.contains("gas tube")) return "gas-tube"; + if (blob.contains("muzzle")) return "muzzle-device"; + if (blob.contains("sling")) return "sling"; + if (blob.contains("bipod")) return "bipod"; + if (blob.contains("tool")) return "tools"; + + return "UNKNOWN"; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java index 5c57802..5733487 100644 --- a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java +++ b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java @@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.GunbuilderProductDto; import org.springframework.stereotype.Service; import group.goforward.ballistic.model.PartRoleMapping; import group.goforward.ballistic.repos.PartRoleMappingRepository; +import group.goforward.ballistic.model.ImportStatus; import java.util.HashMap; import java.util.Map; @@ -47,7 +48,11 @@ public class GunbuilderProductService { } List products = - productRepository.findForGunbuilderByPlatformAndPartRoles(platform, partRoles); + productRepository.findForGunbuilderByPlatformAndPartRoles( + platform, + partRoles, + ImportStatus.MAPPED + ); System.out.println(">>> GB: repo returned " + products.size() + " products"); diff --git a/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java b/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java new file mode 100644 index 0000000..896a493 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java @@ -0,0 +1,42 @@ +// src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.ImportStatusByMerchantDto; +import group.goforward.ballistic.web.dto.ImportStatusSummaryDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ImportStatusAdminService { + + private final ProductRepository productRepository; + + public ImportStatusAdminService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + public List summarizeByStatus() { + return productRepository.aggregateByImportStatus() + .stream() + .map(row -> new ImportStatusSummaryDto( + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } + + public List summarizeByMerchant() { + return productRepository.aggregateByMerchantAndStatus() + .stream() + .map(row -> new ImportStatusByMerchantDto( + (String) row.get("merchantName"), + (String) row.get("platform"), + (ImportStatus) row.get("status"), + (long) row.get("count") + )) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MappingAdminService.java b/src/main/java/group/goforward/ballistic/services/MappingAdminService.java new file mode 100644 index 0000000..4b90881 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/services/MappingAdminService.java @@ -0,0 +1,83 @@ +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class MappingAdminService { + + private final ProductRepository productRepository; + private final MerchantCategoryMappingRepository merchantCategoryMappingRepository; + private final MerchantRepository merchantRepository; + + public MappingAdminService( + ProductRepository productRepository, + MerchantCategoryMappingRepository merchantCategoryMappingRepository, + MerchantRepository merchantRepository + ) { + this.productRepository = productRepository; + this.merchantCategoryMappingRepository = merchantCategoryMappingRepository; + this.merchantRepository = merchantRepository; + } + + @Transactional(readOnly = true) + public List listPendingBuckets() { + List rows = productRepository.findPendingMappingBuckets( + ImportStatus.PENDING_MAPPING // use top-level enum + ); + + return rows.stream() + .map(row -> { + Integer merchantId = (Integer) row[0]; + String merchantName = (String) row[1]; + String rawCategoryKey = (String) row[2]; + String mappedPartRole = (String) row[3]; + Long count = (Long) row[4]; + + return new PendingMappingBucketDto( + merchantId, + merchantName, + rawCategoryKey, + (mappedPartRole != null && !mappedPartRole.isBlank()) ? mappedPartRole : null, + count != null ? count : 0L + ); + }) + .toList(); + } + + @Transactional + public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { + if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) { + throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required"); + } + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + MerchantCategoryMapping mapping = merchantCategoryMappingRepository + .findByMerchantIdAndRawCategory(merchantId, rawCategoryKey) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(rawCategoryKey); + return m; + }); + + mapping.setMappedPartRole(mappedPartRole.trim()); + merchantCategoryMappingRepository.save(mapping); + + // NOTE: + // We're not touching existing Product rows here. + // They will pick up this mapping on the next merchant import, + // which keeps this endpoint fast and side-effect simple. + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index ec963df..a4553c8 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -6,7 +6,6 @@ import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; import jakarta.transaction.Transactional; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Service; @Service diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c2357a4..eea24e5 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -1,44 +1,45 @@ package group.goforward.ballistic.services.impl; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.io.Reader; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import org.springframework.cache.annotation.CacheEvict; - import group.goforward.ballistic.imports.MerchantFeedRow; +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.ImportStatus; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.ProductOffer; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; import group.goforward.ballistic.services.MerchantFeedImportService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import group.goforward.ballistic.model.Brand; -import group.goforward.ballistic.model.Merchant; -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.services.MerchantCategoryMappingService; -import group.goforward.ballistic.model.MerchantCategoryMapping; +import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import group.goforward.ballistic.repos.ProductOfferRepository; -import group.goforward.ballistic.model.ProductOffer; +import java.io.InputStreamReader; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; +import java.util.*; +/** + * Merchant feed ETL + offer sync. + * + * - importMerchantFeed: full ETL (products + offers) + * - syncOffersOnly: only refresh offers/prices/stock from an offers feed + */ @Service @Transactional public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); private final MerchantRepository merchantRepository; @@ -47,11 +48,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantCategoryMappingService merchantCategoryMappingService; private final ProductOfferRepository productOfferRepository; - public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, - BrandRepository brandRepository, - ProductRepository productRepository, - MerchantCategoryMappingService merchantCategoryMappingService, - ProductOfferRepository productOfferRepository) { + public MerchantFeedImportServiceImpl( + MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository, + MerchantCategoryMappingService merchantCategoryMappingService, + ProductOfferRepository productOfferRepository + ) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; @@ -59,6 +62,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService this.productOfferRepository = productOfferRepository; } + // --------------------------------------------------------------------- + // Full product + offer import + // --------------------------------------------------------------------- + @Override @CacheEvict(value = "gunbuilderProducts", allEntries = true) public void importMerchantFeed(Integer merchantId) { @@ -67,27 +74,27 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - // Read all rows from the merchant feed List rows = readFeedRowsForMerchant(merchant); log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); for (MerchantFeedRow row : rows) { Brand brand = resolveBrand(row); Product p = upsertProduct(merchant, brand, row); - log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", + log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}", p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); } } // --------------------------------------------------------------------- - // Upsert logic + // Product upsert // --------------------------------------------------------------------- private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { - log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); + 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 real UPC field + String upc = trimOrNull(row.sku()); // placeholder until a real UPC column exists List candidates = Collections.emptyList(); @@ -114,47 +121,17 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService updateProductFromRow(p, merchant, row, isNew); - // Save the product first Product saved = productRepository.save(p); - // Then upsert the offer for this row upsertOfferFromRow(saved, merchant, row); return saved; } - private List> fetchFeedRows(String feedUrl) { - log.info("Reading offer feed from {}", feedUrl); - List> rows = new ArrayList<>(); - - try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) - ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) - : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); - CSVParser parser = CSVFormat.DEFAULT - .withFirstRecordAsHeader() - .withIgnoreSurroundingSpaces() - .withTrim() - .parse(reader)) { - - // capture header names from the CSV - List headers = new ArrayList<>(parser.getHeaderMap().keySet()); - - for (CSVRecord rec : parser) { - Map row = new HashMap<>(); - for (String header : headers) { - row.put(header, rec.get(header)); - } - rows.add(row); - } - } catch (Exception ex) { - throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); - } - - log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); - return rows; - } - - private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { + private void updateProductFromRow(Product p, + Merchant merchant, + MerchantFeedRow row, + boolean isNew) { // ---------- NAME ---------- String name = coalesce( trimOrNull(row.productName()), @@ -181,6 +158,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService .toLowerCase() .replaceAll("[^a-z0-9]+", "-") .replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { slug = "product-" + System.currentTimeMillis(); } @@ -207,9 +185,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService trimOrNull(row.sku()) ); p.setMpn(mpn); - - // UPC placeholder - p.setUpc(null); + p.setUpc(null); // placeholder // ---------- PLATFORM ---------- if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { @@ -217,82 +193,90 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setPlatform(platform != null ? platform : "AR-15"); } - // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- + // ---------- RAW CATEGORY KEY ---------- String rawCategoryKey = buildRawCategoryKey(row); p.setRawCategoryKey(rawCategoryKey); - // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- + // ---------- PART ROLE (mapping + fallback) ---------- String partRole = null; + // 1) First try merchant category mapping if (rawCategoryKey != null) { - // Ask the mapping service for (or to create) a mapping row MerchantCategoryMapping mapping = merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); - if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { + if (mapping != null && + mapping.getMappedPartRole() != null && + !mapping.getMappedPartRole().isBlank()) { partRole = mapping.getMappedPartRole().trim(); } } - // Fallback: keyword-based inference if we still don't have a mapped partRole + // 2) Fallback to keyword-based inference if (partRole == null || partRole.isBlank()) { partRole = inferPartRole(row); } + // 3) Normalize or default to UNKNOWN if (partRole == null || partRole.isBlank()) { - partRole = "unknown"; + partRole = "UNKNOWN"; + } else { + partRole = partRole.trim(); } p.setPartRole(partRole); + + // ---------- IMPORT STATUS ---------- + if ("UNKNOWN".equalsIgnoreCase(partRole)) { + p.setImportStatus(ImportStatus.PENDING_MAPPING); + } else { + p.setImportStatus(ImportStatus.MAPPED); + } } - private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { - // For now, we’ll use SKU as the "avantlinkProductId" placeholder. - // If/when you have a real AvantLink product_id in the feed, switch to that. + + // --------------------------------------------------------------------- + // Offer upsert (full ETL) + // --------------------------------------------------------------------- + + private void upsertOfferFromRow(Product product, + Merchant merchant, + MerchantFeedRow row) { + String avantlinkProductId = trimOrNull(row.sku()); if (avantlinkProductId == null) { - // If there's truly no SKU, bail out – we can't match this offer reliably. log.debug("Skipping offer row with no SKU for product id={}", product.getId()); return; } - // 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 retail = row.retailPrice(); BigDecimal sale = row.salePrice(); BigDecimal effectivePrice; BigDecimal originalPrice; - // 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 { - // Otherwise fall back to retail or whatever is present effectivePrice = (retail != null ? retail : sale); originalPrice = (retail != null ? retail : sale); } @@ -300,25 +284,129 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService 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); - - // Update "last seen" on every import pass offer.setLastSeenAt(OffsetDateTime.now()); productOfferRepository.save(offer); } - // --------------------------------------------------------------------- - // Feed reading + brand resolution + // Offers-only sync + // --------------------------------------------------------------------- + + @Override + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void syncOffersOnly(Integer merchantId) { + log.info("Starting offers-only sync for merchantId={}", merchantId); + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + if (Boolean.FALSE.equals(merchant.getIsActive())) { + log.info("Merchant {} is inactive, skipping offers-only sync", merchant.getName()); + return; + } + + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null || feedUrl.isBlank()) { + throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); + } + + List> rows = fetchFeedRows(feedUrl); + + for (Map row : rows) { + upsertOfferOnlyFromRow(merchant, row); + } + + merchant.setLastOfferSyncAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + + log.info("Completed offers-only sync for merchantId={} ({} rows processed)", + merchantId, rows.size()); + } + + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // Offers-only sync should not create new offers; skip if missing. + return; + } + + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + String newBuyUrl = trimOrNull(row.get("Buy Link")); + offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); + + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + + private Boolean parseInStock(Map row) { + String inStock = trimOrNull(row.get("In Stock")); + if (inStock == null) return Boolean.FALSE; + + String lower = inStock.toLowerCase(Locale.ROOT); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1") || lower.contains("in stock")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0") || lower.contains("out of stock")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } + + private List> fetchFeedRows(String feedUrl) { + log.info("Reading offer feed from {}", feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + List headers = new ArrayList<>(parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + Map row = new HashMap<>(); + for (String header : headers) { + row.put(header, rec.get(header)); + } + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); + } + + log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); + return rows; + } + + // --------------------------------------------------------------------- + // Feed reading + brand resolution (full ETL) // --------------------------------------------------------------------- - /** - * Open a Reader for either an HTTP(S) URL or a local file path. - */ private Reader openFeedReader(String feedUrl) throws java.io.IOException { if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); @@ -330,14 +418,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } } - /** - * Try a few common delimiters (tab, comma, semicolon) and pick the one - * that yields the expected AvantLink-style header set. - */ private CSVFormat detectCsvFormat(String feedUrl) throws Exception { char[] delimiters = new char[]{'\t', ',', ';'}; - java.util.List requiredHeaders = - java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); + List requiredHeaders = + Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); Exception lastException = null; @@ -364,7 +448,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService .setTrim(true) .build(); } else if (headerMap != null) { - log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); + log.debug("Delimiter '{}' produced headers {} for feed {}", + (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), + headerMap.keySet(), + feedUrl); } } catch (Exception ex) { lastException = ex; @@ -390,13 +477,14 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService List rows = new ArrayList<>(); try { - // Auto-detect delimiter (TSV/CSV/semicolon) based on header row CSVFormat format = detectCsvFormat(feedUrl); try (Reader reader = openFeedReader(feedUrl); CSVParser parser = new CSVParser(reader, format)) { - log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); + log.debug("Detected feed headers for merchant {}: {}", + merchant.getName(), + parser.getHeaderMap().keySet()); for (CSVRecord rec : parser) { MerchantFeedRow row = new MerchantFeedRow( @@ -441,7 +529,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private Brand resolveBrand(MerchantFeedRow row) { String rawBrand = trimOrNull(row.brandName()); final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; - + return brandRepository.findByNameIgnoreCase(brandName) .orElseGet(() -> { Brand b = new Brand(); @@ -450,9 +538,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService }); } - private String getCol(String[] cols, int index) { - return (index >= 0 && index < cols.length) ? cols[index] : null; - } + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- private BigDecimal parseBigDecimal(String raw) { if (raw == null) return null; @@ -466,31 +554,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } } - /** - * Safely get a column value by header name. If the record is "short" - * (fewer values than headers) or the header is missing, return null - * instead of throwing IllegalArgumentException. - */ private String getCsvValue(CSVRecord rec, String header) { if (rec == null || header == null) { return null; } if (!rec.isMapped(header)) { - // Header not present at all return null; } try { return rec.get(header); } catch (IllegalArgumentException ex) { - log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); + log.debug("Short CSV record #{} missing column '{}', treating as null", + rec.getRecordNumber(), header); return null; } } - // --------------------------------------------------------------------- - // Misc helpers - // --------------------------------------------------------------------- - private String trimOrNull(String value) { if (value == null) return null; String trimmed = value.trim(); @@ -522,7 +601,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService String cat = trimOrNull(row.category()); String sub = trimOrNull(row.subCategory()); - java.util.List parts = new java.util.ArrayList<>(); + List parts = new ArrayList<>(); if (dept != null) parts.add(dept); if (cat != null) parts.add(cat); if (sub != null) parts.add(sub); @@ -535,10 +614,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } private String inferPlatform(MerchantFeedRow row) { - String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); + String department = coalesce( + trimOrNull(row.department()), + trimOrNull(row.category()) + ); if (department == null) return null; - String lower = department.toLowerCase(); + 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"; @@ -553,7 +635,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService ); if (cat == null) return null; - String lower = cat.toLowerCase(); + String lower = cat.toLowerCase(Locale.ROOT); if (lower.contains("handguard") || lower.contains("rail")) { return "handguard"; @@ -579,82 +661,4 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return "unknown"; } - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void syncOffersOnly(Integer merchantId) { - log.info("Starting offers-only sync for merchantId={}", merchantId); - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new RuntimeException("Merchant not found")); - - if (Boolean.FALSE.equals(merchant.getIsActive())) { - return; - } - - String feedUrl = merchant.getOfferFeedUrl() != null - ? merchant.getOfferFeedUrl() - : merchant.getFeedUrl(); - - if (feedUrl == null) { - throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); - } - - List> rows = fetchFeedRows(feedUrl); - - for (Map row : rows) { - upsertOfferOnlyFromRow(merchant, row); - } - - merchant.setLastOfferSyncAt(OffsetDateTime.now()); - merchantRepository.save(merchant); - log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); - } - - private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { - // For the offer-only sync, we key offers by the same identifier we used when creating them. - // In the current AvantLink-style feed, that is the SKU column. - String avantlinkProductId = trimOrNull(row.get("SKU")); - if (avantlinkProductId == null || avantlinkProductId.isBlank()) { - return; - } - - // Find existing offer - ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) - .orElse(null); - - if (offer == null) { - // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. - return; - } - - // Parse price fields (column names match the main product feed) - BigDecimal price = parseBigDecimal(row.get("Sale Price")); - BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); - - // Update only *offer* fields – do not touch Product - offer.setPrice(price); - offer.setOriginalPrice(originalPrice); - offer.setInStock(parseInStock(row)); - - // Prefer a fresh Buy Link from the feed if present, otherwise keep existing - String newBuyUrl = trimOrNull(row.get("Buy Link")); - offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); - - offer.setLastSeenAt(OffsetDateTime.now()); - - productOfferRepository.save(offer); - } - private Boolean parseInStock(Map row) { - String inStock = trimOrNull(row.get("In Stock")); - if (inStock == null) return Boolean.FALSE; - - String lower = inStock.toLowerCase(); - if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { - return Boolean.TRUE; - } - if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { - return Boolean.FALSE; - } - - return Boolean.FALSE; - } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java b/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java new file mode 100644 index 0000000..740320f --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java @@ -0,0 +1,40 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.MappingAdminService; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/mappings") +public class CategoryMappingAdminController { + + private final MappingAdminService mappingAdminService; + + public CategoryMappingAdminController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending") + public List listPending() { + return mappingAdminService.listPendingBuckets(); + } + + public record ApplyMappingRequest( + Integer merchantId, + String rawCategoryKey, + String mappedPartRole + ) {} + + @PostMapping("/apply") + public ResponseEntity apply(@RequestBody ApplyMappingRequest request) { + mappingAdminService.applyMapping( + request.merchantId(), + request.rawCategoryKey(), + request.mappedPartRole() + ); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java b/src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java new file mode 100644 index 0000000..51f2d8c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java @@ -0,0 +1,32 @@ +// src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.ImportStatusAdminService; +import group.goforward.ballistic.web.dto.ImportStatusByMerchantDto; +import group.goforward.ballistic.web.dto.ImportStatusSummaryDto; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/import-status") +public class ImportStatusAdminController { + + private final ImportStatusAdminService service; + + public ImportStatusAdminController(ImportStatusAdminService service) { + this.service = service; + } + + @GetMapping("/summary") + public List summary() { + return service.summarizeByStatus(); + } + + @GetMapping("/by-merchant") + public List byMerchant() { + return service.summarizeByMerchant(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java b/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java new file mode 100644 index 0000000..04502df --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java @@ -0,0 +1,10 @@ +// src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java +package group.goforward.ballistic.web.dto; + +public record CategoryMappingRecommendationDto( + String merchantName, + String rawCategoryKey, + long productCount, + String recommendedPartRole, + String sampleProductName +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java new file mode 100644 index 0000000..1bb2f92 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java @@ -0,0 +1,11 @@ +// src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java +package group.goforward.ballistic.web.dto; + +import group.goforward.ballistic.model.ImportStatus; + +public record ImportStatusByMerchantDto( + String merchantName, + String platform, + ImportStatus status, + long count +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java new file mode 100644 index 0000000..6bf8cb5 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java @@ -0,0 +1,9 @@ +// src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java +package group.goforward.ballistic.web.dto; + +import group.goforward.ballistic.model.ImportStatus; + +public record ImportStatusSummaryDto( + ImportStatus status, + long count +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java b/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java new file mode 100644 index 0000000..74c70a8 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java @@ -0,0 +1,9 @@ +package group.goforward.ballistic.web.dto; + +public record PendingMappingBucketDto( + Integer merchantId, + String merchantName, + String rawCategoryKey, + String mappedPartRole, + long productCount +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/admin/AdminMappingController.java b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminMappingController.java new file mode 100644 index 0000000..3ef41c3 --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/admin/AdminMappingController.java @@ -0,0 +1,35 @@ +package group.goforward.ballistic.web.admin; + +import group.goforward.ballistic.services.MappingAdminService; +import group.goforward.ballistic.web.dto.PendingMappingBucketDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/mapping") +public class AdminMappingController { + + private final MappingAdminService mappingAdminService; + + public AdminMappingController(MappingAdminService mappingAdminService) { + this.mappingAdminService = mappingAdminService; + } + + @GetMapping("/pending-buckets") + public List listPendingBuckets() { + return mappingAdminService.listPendingBuckets(); + } + + @PostMapping("/apply") + public ResponseEntity applyMapping(@RequestBody Map body) { + Integer merchantId = (Integer) body.get("merchantId"); + String rawCategoryKey = (String) body.get("rawCategoryKey"); + String mappedPartRole = (String) body.get("mappedPartRole"); + + mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole); + return ResponseEntity.ok(Map.of("ok", true)); + } +} \ No newline at end of file