mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
updating category/role mapping. new endpoints for unmapped parts and apis for front end
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -9,9 +9,6 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.ProductOffer;
|
|
||||||
import group.goforward.ballistic.model.ProductConfiguration;
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "products")
|
@Table(name = "products")
|
||||||
@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
|
@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
|
||||||
@@ -32,11 +29,10 @@ import group.goforward.ballistic.model.ProductConfiguration;
|
|||||||
" LEFT JOIN FETCH p.offers o" +
|
" LEFT JOIN FETCH p.offers o" +
|
||||||
" WHERE p.platform = :platform" +
|
" WHERE p.platform = :platform" +
|
||||||
" AND p.deletedAt IS NULL")
|
" AND p.deletedAt IS NULL")
|
||||||
|
|
||||||
public class Product {
|
public class Product {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Column(name = "id", nullable = false)
|
@Column(name = "id", nullable = false)
|
||||||
private Integer id;
|
private Integer id;
|
||||||
|
|
||||||
@@ -93,31 +89,20 @@ public class Product {
|
|||||||
@Column(name = "platform_locked", nullable = false)
|
@Column(name = "platform_locked", nullable = false)
|
||||||
private Boolean platformLocked = 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)
|
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
|
||||||
private Set<ProductOffer> offers = new HashSet<>();
|
private Set<ProductOffer> offers = new HashSet<>();
|
||||||
|
|
||||||
public Set<ProductOffer> getOffers() {
|
|
||||||
return offers;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOffers(Set<ProductOffer> offers) {
|
|
||||||
this.offers = offers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- lifecycle hooks ---
|
// --- lifecycle hooks ---
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
public void prePersist() {
|
public void prePersist() {
|
||||||
if (uuid == null) {
|
if (uuid == null) uuid = UUID.randomUUID();
|
||||||
uuid = UUID.randomUUID();
|
|
||||||
}
|
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
if (createdAt == null) {
|
if (createdAt == null) createdAt = now;
|
||||||
createdAt = now;
|
if (updatedAt == null) updatedAt = now;
|
||||||
}
|
|
||||||
if (updatedAt == null) {
|
|
||||||
updatedAt = now;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
@@ -125,181 +110,101 @@ public class Product {
|
|||||||
updatedAt = Instant.now();
|
updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getRawCategoryKey() {
|
|
||||||
return rawCategoryKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRawCategoryKey(String rawCategoryKey) {
|
|
||||||
this.rawCategoryKey = rawCategoryKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- getters & setters ---
|
// --- getters & setters ---
|
||||||
|
public Integer getId() { return id; }
|
||||||
|
public void setId(Integer id) { this.id = id; }
|
||||||
|
|
||||||
public Integer getId() {
|
public UUID getUuid() { return uuid; }
|
||||||
return id;
|
public void setUuid(UUID uuid) { this.uuid = uuid; }
|
||||||
}
|
|
||||||
|
public Brand getBrand() { return brand; }
|
||||||
public void setId(Integer id) {
|
public void setBrand(Brand brand) { this.brand = brand; }
|
||||||
this.id = id;
|
|
||||||
}
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
public UUID getUuid() {
|
|
||||||
return uuid;
|
public String getSlug() { return slug; }
|
||||||
}
|
public void setSlug(String slug) { this.slug = slug; }
|
||||||
|
|
||||||
public void setUuid(UUID uuid) {
|
public String getMpn() { return mpn; }
|
||||||
this.uuid = uuid;
|
public void setMpn(String mpn) { this.mpn = mpn; }
|
||||||
}
|
|
||||||
|
public String getUpc() { return upc; }
|
||||||
public Brand getBrand() {
|
public void setUpc(String upc) { this.upc = upc; }
|
||||||
return brand;
|
|
||||||
}
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
public void setBrand(Brand brand) {
|
|
||||||
this.brand = brand;
|
public String getPartRole() { return partRole; }
|
||||||
}
|
public void setPartRole(String partRole) { this.partRole = partRole; }
|
||||||
|
|
||||||
public String getName() {
|
public ProductConfiguration getConfiguration() { return configuration; }
|
||||||
return name;
|
public void setConfiguration(ProductConfiguration configuration) {
|
||||||
}
|
this.configuration = configuration;
|
||||||
|
|
||||||
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 String getShortDescription() { return shortDescription; }
|
||||||
public void setShortDescription(String shortDescription) {
|
public void setShortDescription(String shortDescription) {
|
||||||
this.shortDescription = shortDescription;
|
this.shortDescription = shortDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() { return description; }
|
||||||
return description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDescription(String description) {
|
public void setDescription(String description) {
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getMainImageUrl() {
|
public String getMainImageUrl() { return mainImageUrl; }
|
||||||
return mainImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMainImageUrl(String mainImageUrl) {
|
public void setMainImageUrl(String mainImageUrl) {
|
||||||
this.mainImageUrl = mainImageUrl;
|
this.mainImageUrl = mainImageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
return createdAt;
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public void setCreatedAt(Instant createdAt) {
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
this.createdAt = createdAt;
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getUpdatedAt() {
|
public Instant getDeletedAt() { return deletedAt; }
|
||||||
return updatedAt;
|
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
||||||
}
|
|
||||||
|
|
||||||
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 Boolean getPlatformLocked() { return platformLocked; }
|
||||||
public void setPlatformLocked(Boolean platformLocked) {
|
public void setPlatformLocked(Boolean platformLocked) {
|
||||||
this.platformLocked = platformLocked;
|
this.platformLocked = platformLocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProductConfiguration getConfiguration() {
|
public String getRawCategoryKey() { return rawCategoryKey; }
|
||||||
return configuration;
|
public void setRawCategoryKey(String rawCategoryKey) {
|
||||||
|
this.rawCategoryKey = rawCategoryKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setConfiguration(ProductConfiguration configuration) {
|
public ImportStatus getImportStatus() { return importStatus; }
|
||||||
this.configuration = configuration;
|
public void setImportStatus(ImportStatus importStatus) {
|
||||||
}
|
this.importStatus = importStatus;
|
||||||
// Convenience: best offer price for Gunbuilder
|
|
||||||
public BigDecimal getBestOfferPrice() {
|
|
||||||
if (offers == null || offers.isEmpty()) {
|
|
||||||
return BigDecimal.ZERO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<ProductOffer> getOffers() { return offers; }
|
||||||
|
public void setOffers(Set<ProductOffer> offers) { this.offers = offers; }
|
||||||
|
|
||||||
|
// --- computed helpers ---
|
||||||
|
|
||||||
|
public BigDecimal getBestOfferPrice() {
|
||||||
|
if (offers == null || offers.isEmpty()) return BigDecimal.ZERO;
|
||||||
|
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
// pick sale_price if present, otherwise retail_price
|
.map(offer -> offer.getSalePrice() != null
|
||||||
.map(offer -> {
|
? offer.getSalePrice()
|
||||||
if (offer.getSalePrice() != null) {
|
: offer.getRetailPrice())
|
||||||
return offer.getSalePrice();
|
|
||||||
}
|
|
||||||
return offer.getRetailPrice();
|
|
||||||
})
|
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.min(BigDecimal::compareTo)
|
.min(BigDecimal::compareTo)
|
||||||
.orElse(BigDecimal.ZERO);
|
.orElse(BigDecimal.ZERO);
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience: URL for the best-priced offer
|
|
||||||
public String getBestOfferBuyUrl() {
|
|
||||||
if (offers == null || offers.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBestOfferBuyUrl() {
|
||||||
|
if (offers == null || offers.isEmpty()) return null;
|
||||||
|
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
.sorted(Comparator.comparing(offer -> {
|
.sorted(Comparator.comparing(offer -> {
|
||||||
if (offer.getSalePrice() != null) {
|
if (offer.getSalePrice() != null) return offer.getSalePrice();
|
||||||
return offer.getSalePrice();
|
|
||||||
}
|
|
||||||
return offer.getRetailPrice();
|
return offer.getRetailPrice();
|
||||||
}, Comparator.nullsLast(BigDecimal::compareTo)))
|
}, Comparator.nullsLast(BigDecimal::compareTo)))
|
||||||
.map(ProductOffer::getAffiliateUrl)
|
.map(ProductOffer::getAffiliateUrl)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package group.goforward.ballistic.repos;
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -13,5 +14,15 @@ public interface MerchantCategoryMappingRepository
|
|||||||
String rawCategory
|
String rawCategory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Optional<MerchantCategoryMapping> findByMerchantIdAndRawCategory(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategory
|
||||||
|
);
|
||||||
|
|
||||||
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
|
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
|
||||||
|
|
||||||
|
Optional<MerchantCategoryMapping> findByMerchantAndRawCategoryIgnoreCase(
|
||||||
|
Merchant merchant,
|
||||||
|
String rawCategory
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package group.goforward.ballistic.repos;
|
|||||||
|
|
||||||
import group.goforward.ballistic.model.ProductOffer;
|
import group.goforward.ballistic.model.ProductOffer;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -19,4 +20,15 @@ public interface ProductOfferRepository extends JpaRepository<ProductOffer, Inte
|
|||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String avantlinkProductId
|
String avantlinkProductId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT m.name, p.platform, p.importStatus, COUNT(DISTINCT p.id)
|
||||||
|
FROM ProductOffer o
|
||||||
|
JOIN o.product p
|
||||||
|
JOIN o.merchant m
|
||||||
|
WHERE p.deletedAt IS NULL
|
||||||
|
GROUP BY m.name, p.platform, p.importStatus
|
||||||
|
ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC
|
||||||
|
""")
|
||||||
|
List<Object[]> countByMerchantPlatformAndStatus();
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package group.goforward.ballistic.repos;
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.Brand;
|
import group.goforward.ballistic.model.Brand;
|
||||||
|
import group.goforward.ballistic.model.ImportStatus;
|
||||||
import group.goforward.ballistic.model.Product;
|
import group.goforward.ballistic.model.Product;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
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.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||||
|
|
||||||
@@ -65,7 +67,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
List<Product> findTop5ByPlatformWithBrand(@Param("platform") String platform);
|
List<Product> findTop5ByPlatformWithBrand(@Param("platform") String platform);
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Used by GunbuilderProductService
|
// Used by GunbuilderProductService (builder UI)
|
||||||
|
// Only returns MAPPED products
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
@@ -75,10 +78,98 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
LEFT JOIN FETCH p.offers o
|
LEFT JOIN FETCH p.offers o
|
||||||
WHERE p.platform = :platform
|
WHERE p.platform = :platform
|
||||||
AND p.partRole IN :partRoles
|
AND p.partRole IN :partRoles
|
||||||
|
AND p.importStatus = :status
|
||||||
AND p.deletedAt IS NULL
|
AND p.deletedAt IS NULL
|
||||||
""")
|
""")
|
||||||
List<Product> findForGunbuilderByPlatformAndPartRoles(
|
List<Product> findForGunbuilderByPlatformAndPartRoles(
|
||||||
@Param("platform") String platform,
|
@Param("platform") String platform,
|
||||||
@Param("partRoles") Collection<String> partRoles
|
@Param("partRoles") Collection<String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Product> 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<Object[]> findPendingMappingBuckets(
|
||||||
|
@Param("status") ImportStatus status
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<CategoryMappingRecommendationDto> 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<Product> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.GunbuilderProductDto;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import group.goforward.ballistic.model.PartRoleMapping;
|
import group.goforward.ballistic.model.PartRoleMapping;
|
||||||
import group.goforward.ballistic.repos.PartRoleMappingRepository;
|
import group.goforward.ballistic.repos.PartRoleMappingRepository;
|
||||||
|
import group.goforward.ballistic.model.ImportStatus;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -47,7 +48,11 @@ public class GunbuilderProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Product> products =
|
List<Product> products =
|
||||||
productRepository.findForGunbuilderByPlatformAndPartRoles(platform, partRoles);
|
productRepository.findForGunbuilderByPlatformAndPartRoles(
|
||||||
|
platform,
|
||||||
|
partRoles,
|
||||||
|
ImportStatus.MAPPED
|
||||||
|
);
|
||||||
|
|
||||||
System.out.println(">>> GB: repo returned " + products.size() + " products");
|
System.out.println(">>> GB: repo returned " + products.size() + " products");
|
||||||
|
|
||||||
|
|||||||
@@ -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<ImportStatusSummaryDto> summarizeByStatus() {
|
||||||
|
return productRepository.aggregateByImportStatus()
|
||||||
|
.stream()
|
||||||
|
.map(row -> new ImportStatusSummaryDto(
|
||||||
|
(ImportStatus) row.get("status"),
|
||||||
|
(long) row.get("count")
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ImportStatusByMerchantDto> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
|
List<Object[]> 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import group.goforward.ballistic.model.ProductConfiguration;
|
|||||||
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
|
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
package group.goforward.ballistic.services.impl;
|
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.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 group.goforward.ballistic.services.MerchantFeedImportService;
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVParser;
|
import org.apache.commons.csv.CSVParser;
|
||||||
import org.apache.commons.csv.CSVRecord;
|
import org.apache.commons.csv.CSVRecord;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
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.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.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
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||||
|
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
@@ -47,11 +48,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
|
public MerchantFeedImportServiceImpl(
|
||||||
|
MerchantRepository merchantRepository,
|
||||||
BrandRepository brandRepository,
|
BrandRepository brandRepository,
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||||
ProductOfferRepository productOfferRepository) {
|
ProductOfferRepository productOfferRepository
|
||||||
|
) {
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.brandRepository = brandRepository;
|
this.brandRepository = brandRepository;
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
@@ -59,6 +62,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Full product + offer import
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||||
public void importMerchantFeed(Integer merchantId) {
|
public void importMerchantFeed(Integer merchantId) {
|
||||||
@@ -67,27 +74,27 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||||
|
|
||||||
// Read all rows from the merchant feed
|
|
||||||
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
||||||
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
||||||
|
|
||||||
for (MerchantFeedRow row : rows) {
|
for (MerchantFeedRow row : rows) {
|
||||||
Brand brand = resolveBrand(row);
|
Brand brand = resolveBrand(row);
|
||||||
Product p = upsertProduct(merchant, brand, 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());
|
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) {
|
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 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<Product> candidates = Collections.emptyList();
|
List<Product> candidates = Collections.emptyList();
|
||||||
|
|
||||||
@@ -114,47 +121,17 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
|
|
||||||
updateProductFromRow(p, merchant, row, isNew);
|
updateProductFromRow(p, merchant, row, isNew);
|
||||||
|
|
||||||
// Save the product first
|
|
||||||
Product saved = productRepository.save(p);
|
Product saved = productRepository.save(p);
|
||||||
|
|
||||||
// Then upsert the offer for this row
|
|
||||||
upsertOfferFromRow(saved, merchant, row);
|
upsertOfferFromRow(saved, merchant, row);
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
private List<Map<String, String>> fetchFeedRows(String feedUrl) {
|
|
||||||
log.info("Reading offer feed from {}", feedUrl);
|
|
||||||
|
|
||||||
List<Map<String, String>> rows = new ArrayList<>();
|
private void updateProductFromRow(Product p,
|
||||||
|
Merchant merchant,
|
||||||
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
|
MerchantFeedRow row,
|
||||||
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
|
boolean isNew) {
|
||||||
: 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<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
|
|
||||||
|
|
||||||
for (CSVRecord rec : parser) {
|
|
||||||
Map<String, String> 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) {
|
|
||||||
// ---------- NAME ----------
|
// ---------- NAME ----------
|
||||||
String name = coalesce(
|
String name = coalesce(
|
||||||
trimOrNull(row.productName()),
|
trimOrNull(row.productName()),
|
||||||
@@ -181,6 +158,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll("[^a-z0-9]+", "-")
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
.replaceAll("(^-|-$)", "");
|
.replaceAll("(^-|-$)", "");
|
||||||
|
|
||||||
if (slug.isBlank()) {
|
if (slug.isBlank()) {
|
||||||
slug = "product-" + System.currentTimeMillis();
|
slug = "product-" + System.currentTimeMillis();
|
||||||
}
|
}
|
||||||
@@ -207,9 +185,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
trimOrNull(row.sku())
|
trimOrNull(row.sku())
|
||||||
);
|
);
|
||||||
p.setMpn(mpn);
|
p.setMpn(mpn);
|
||||||
|
p.setUpc(null); // placeholder
|
||||||
// UPC placeholder
|
|
||||||
p.setUpc(null);
|
|
||||||
|
|
||||||
// ---------- PLATFORM ----------
|
// ---------- PLATFORM ----------
|
||||||
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
||||||
@@ -217,82 +193,90 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
p.setPlatform(platform != null ? platform : "AR-15");
|
p.setPlatform(platform != null ? platform : "AR-15");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- RAW CATEGORY KEY (for debugging / analytics) ----------
|
// ---------- RAW CATEGORY KEY ----------
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
p.setRawCategoryKey(rawCategoryKey);
|
p.setRawCategoryKey(rawCategoryKey);
|
||||||
|
|
||||||
// ---------- PART ROLE (via category mapping, with keyword fallback) ----------
|
// ---------- PART ROLE (mapping + fallback) ----------
|
||||||
String partRole = null;
|
String partRole = null;
|
||||||
|
|
||||||
|
// 1) First try merchant category mapping
|
||||||
if (rawCategoryKey != null) {
|
if (rawCategoryKey != null) {
|
||||||
// Ask the mapping service for (or to create) a mapping row
|
|
||||||
MerchantCategoryMapping mapping =
|
MerchantCategoryMapping mapping =
|
||||||
merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey);
|
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();
|
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()) {
|
if (partRole == null || partRole.isBlank()) {
|
||||||
partRole = inferPartRole(row);
|
partRole = inferPartRole(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Normalize or default to UNKNOWN
|
||||||
if (partRole == null || partRole.isBlank()) {
|
if (partRole == null || partRole.isBlank()) {
|
||||||
partRole = "unknown";
|
partRole = "UNKNOWN";
|
||||||
|
} else {
|
||||||
|
partRole = partRole.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
p.setPartRole(partRole);
|
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());
|
String avantlinkProductId = trimOrNull(row.sku());
|
||||||
if (avantlinkProductId == null) {
|
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());
|
log.debug("Skipping offer row with no SKU for product id={}", product.getId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent upsert: look for an existing offer for this merchant + AvantLink product id
|
|
||||||
ProductOffer offer = productOfferRepository
|
ProductOffer offer = productOfferRepository
|
||||||
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
||||||
.orElseGet(ProductOffer::new);
|
.orElseGet(ProductOffer::new);
|
||||||
|
|
||||||
// If this is a brand‑new offer, initialize key fields
|
|
||||||
if (offer.getId() == null) {
|
if (offer.getId() == null) {
|
||||||
offer.setMerchant(merchant);
|
offer.setMerchant(merchant);
|
||||||
offer.setProduct(product);
|
offer.setProduct(product);
|
||||||
offer.setAvantlinkProductId(avantlinkProductId);
|
offer.setAvantlinkProductId(avantlinkProductId);
|
||||||
offer.setFirstSeenAt(OffsetDateTime.now());
|
offer.setFirstSeenAt(OffsetDateTime.now());
|
||||||
} else {
|
} else {
|
||||||
// Make sure associations stay in sync if anything changed
|
|
||||||
offer.setMerchant(merchant);
|
offer.setMerchant(merchant);
|
||||||
offer.setProduct(product);
|
offer.setProduct(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identifiers
|
|
||||||
offer.setSku(trimOrNull(row.sku()));
|
offer.setSku(trimOrNull(row.sku()));
|
||||||
// No real UPC in this feed yet – leave null for now
|
|
||||||
offer.setUpc(null);
|
offer.setUpc(null);
|
||||||
|
|
||||||
// Buy URL
|
|
||||||
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
||||||
|
|
||||||
// Prices from feed
|
BigDecimal retail = row.retailPrice();
|
||||||
BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant
|
|
||||||
BigDecimal sale = row.salePrice();
|
BigDecimal sale = row.salePrice();
|
||||||
|
|
||||||
BigDecimal effectivePrice;
|
BigDecimal effectivePrice;
|
||||||
BigDecimal originalPrice;
|
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)) {
|
if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) {
|
||||||
effectivePrice = sale;
|
effectivePrice = sale;
|
||||||
originalPrice = (retail != null ? retail : sale);
|
originalPrice = (retail != null ? retail : sale);
|
||||||
} else {
|
} else {
|
||||||
// Otherwise fall back to retail or whatever is present
|
|
||||||
effectivePrice = (retail != null ? retail : sale);
|
effectivePrice = (retail != null ? retail : sale);
|
||||||
originalPrice = (retail != null ? retail : sale);
|
originalPrice = (retail != null ? retail : sale);
|
||||||
}
|
}
|
||||||
@@ -300,25 +284,129 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
offer.setPrice(effectivePrice);
|
offer.setPrice(effectivePrice);
|
||||||
offer.setOriginalPrice(originalPrice);
|
offer.setOriginalPrice(originalPrice);
|
||||||
|
|
||||||
// Currency + stock
|
|
||||||
offer.setCurrency("USD");
|
offer.setCurrency("USD");
|
||||||
// We don't have a real stock flag in this CSV, so assume in-stock for now
|
|
||||||
offer.setInStock(Boolean.TRUE);
|
offer.setInStock(Boolean.TRUE);
|
||||||
|
|
||||||
// Update "last seen" on every import pass
|
|
||||||
offer.setLastSeenAt(OffsetDateTime.now());
|
offer.setLastSeenAt(OffsetDateTime.now());
|
||||||
|
|
||||||
productOfferRepository.save(offer);
|
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<Map<String, String>> rows = fetchFeedRows(feedUrl);
|
||||||
|
|
||||||
|
for (Map<String, String> 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<String, String> 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<String, String> 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<Map<String, String>> fetchFeedRows(String feedUrl) {
|
||||||
|
log.info("Reading offer feed from {}", feedUrl);
|
||||||
|
|
||||||
|
List<Map<String, String>> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Reader reader = openFeedReader(feedUrl);
|
||||||
|
CSVParser parser = CSVFormat.DEFAULT
|
||||||
|
.withFirstRecordAsHeader()
|
||||||
|
.withIgnoreSurroundingSpaces()
|
||||||
|
.withTrim()
|
||||||
|
.parse(reader)) {
|
||||||
|
|
||||||
|
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
|
||||||
|
|
||||||
|
for (CSVRecord rec : parser) {
|
||||||
|
Map<String, String> 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 {
|
private Reader openFeedReader(String feedUrl) throws java.io.IOException {
|
||||||
if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) {
|
if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) {
|
||||||
return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8);
|
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 {
|
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
||||||
char[] delimiters = new char[]{'\t', ',', ';'};
|
char[] delimiters = new char[]{'\t', ',', ';'};
|
||||||
java.util.List<String> requiredHeaders =
|
List<String> requiredHeaders =
|
||||||
java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
|
Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
|
||||||
|
|
||||||
Exception lastException = null;
|
Exception lastException = null;
|
||||||
|
|
||||||
@@ -364,7 +448,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
.setTrim(true)
|
.setTrim(true)
|
||||||
.build();
|
.build();
|
||||||
} else if (headerMap != null) {
|
} 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) {
|
} catch (Exception ex) {
|
||||||
lastException = ex;
|
lastException = ex;
|
||||||
@@ -390,13 +477,14 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
List<MerchantFeedRow> rows = new ArrayList<>();
|
List<MerchantFeedRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Auto-detect delimiter (TSV/CSV/semicolon) based on header row
|
|
||||||
CSVFormat format = detectCsvFormat(feedUrl);
|
CSVFormat format = detectCsvFormat(feedUrl);
|
||||||
|
|
||||||
try (Reader reader = openFeedReader(feedUrl);
|
try (Reader reader = openFeedReader(feedUrl);
|
||||||
CSVParser parser = new CSVParser(reader, format)) {
|
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) {
|
for (CSVRecord rec : parser) {
|
||||||
MerchantFeedRow row = new MerchantFeedRow(
|
MerchantFeedRow row = new MerchantFeedRow(
|
||||||
@@ -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) {
|
private BigDecimal parseBigDecimal(String raw) {
|
||||||
if (raw == null) return null;
|
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) {
|
private String getCsvValue(CSVRecord rec, String header) {
|
||||||
if (rec == null || header == null) {
|
if (rec == null || header == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!rec.isMapped(header)) {
|
if (!rec.isMapped(header)) {
|
||||||
// Header not present at all
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return rec.get(header);
|
return rec.get(header);
|
||||||
} catch (IllegalArgumentException ex) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Misc helpers
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private String trimOrNull(String value) {
|
private String trimOrNull(String value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
String trimmed = value.trim();
|
String trimmed = value.trim();
|
||||||
@@ -522,7 +601,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
String cat = trimOrNull(row.category());
|
String cat = trimOrNull(row.category());
|
||||||
String sub = trimOrNull(row.subCategory());
|
String sub = trimOrNull(row.subCategory());
|
||||||
|
|
||||||
java.util.List<String> parts = new java.util.ArrayList<>();
|
List<String> parts = new ArrayList<>();
|
||||||
if (dept != null) parts.add(dept);
|
if (dept != null) parts.add(dept);
|
||||||
if (cat != null) parts.add(cat);
|
if (cat != null) parts.add(cat);
|
||||||
if (sub != null) parts.add(sub);
|
if (sub != null) parts.add(sub);
|
||||||
@@ -535,10 +614,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String inferPlatform(MerchantFeedRow row) {
|
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;
|
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-15") || lower.contains("ar15")) return "AR-15";
|
||||||
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
|
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
|
||||||
if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47";
|
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;
|
if (cat == null) return null;
|
||||||
|
|
||||||
String lower = cat.toLowerCase();
|
String lower = cat.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (lower.contains("handguard") || lower.contains("rail")) {
|
if (lower.contains("handguard") || lower.contains("rail")) {
|
||||||
return "handguard";
|
return "handguard";
|
||||||
@@ -579,82 +661,4 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
|
|
||||||
return "unknown";
|
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<Map<String, String>> rows = fetchFeedRows(feedUrl);
|
|
||||||
|
|
||||||
for (Map<String, String> 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<String, String> 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<String, String> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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<PendingMappingBucketDto> listPending() {
|
||||||
|
return mappingAdminService.listPendingBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApplyMappingRequest(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategoryKey,
|
||||||
|
String mappedPartRole
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@PostMapping("/apply")
|
||||||
|
public ResponseEntity<Void> apply(@RequestBody ApplyMappingRequest request) {
|
||||||
|
mappingAdminService.applyMapping(
|
||||||
|
request.merchantId(),
|
||||||
|
request.rawCategoryKey(),
|
||||||
|
request.mappedPartRole()
|
||||||
|
);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ImportStatusSummaryDto> summary() {
|
||||||
|
return service.summarizeByStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/by-merchant")
|
||||||
|
public List<ImportStatusByMerchantDto> byMerchant() {
|
||||||
|
return service.summarizeByMerchant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
public record PendingMappingBucketDto(
|
||||||
|
Integer merchantId,
|
||||||
|
String merchantName,
|
||||||
|
String rawCategoryKey,
|
||||||
|
String mappedPartRole,
|
||||||
|
long productCount
|
||||||
|
) {}
|
||||||
@@ -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<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
|
return mappingAdminService.listPendingBuckets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/apply")
|
||||||
|
public ResponseEntity<?> applyMapping(@RequestBody Map<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user