Compare commits

...

3 Commits

Author SHA1 Message Date
756a6791fc fixed merge conflict 2025-12-03 22:12:46 -05:00
74a5c42e26 dynamic category mapping and updating from admin. 2025-12-03 21:50:00 -05:00
5e3f7d5044 expanded category grouping from db 2025-12-03 19:13:43 -05:00
18 changed files with 730 additions and 100 deletions

View File

@@ -0,0 +1,40 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/categories")
@CrossOrigin
public class AdminCategoryController {
private final PartCategoryRepository partCategories;
public AdminCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> listCategories() {
return partCategories
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(this::toDto)
.toList();
}
private PartCategoryDto toDto(PartCategory entity) {
return new PartCategoryDto(
entity.getId(),
entity.getSlug(),
entity.getName(),
entity.getDescription(),
entity.getGroupName(),
entity.getSortOrder()
);
}
}

View File

@@ -0,0 +1,125 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.AffiliateCategoryMap;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.CategoryMappingRepository;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/admin/category-mappings")
@CrossOrigin
public class AdminCategoryMappingController {
private final CategoryMappingRepository categoryMappingRepository;
private final PartCategoryRepository partCategoryRepository;
public AdminCategoryMappingController(
CategoryMappingRepository categoryMappingRepository,
PartCategoryRepository partCategoryRepository
) {
this.categoryMappingRepository = categoryMappingRepository;
this.partCategoryRepository = partCategoryRepository;
}
// GET /api/admin/category-mappings?platform=AR-15
@GetMapping
public List<PartRoleMappingDto> list(
@RequestParam(name = "platform", defaultValue = "AR-15") String platform
) {
List<AffiliateCategoryMap> mappings =
categoryMappingRepository.findBySourceTypeAndPlatformOrderById("PART_ROLE", platform);
return mappings.stream()
.map(this::toDto)
.toList();
}
// POST /api/admin/category-mappings
@PostMapping
public ResponseEntity<PartRoleMappingDto> create(
@RequestBody PartRoleMappingRequest request
) {
if (request.platform() == null || request.partRole() == null || request.categorySlug() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "platform, partRole, and categorySlug are required");
}
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unknown category slug: " + request.categorySlug()
));
AffiliateCategoryMap mapping = new AffiliateCategoryMap();
mapping.setSourceType("PART_ROLE");
mapping.setSourceValue(request.partRole());
mapping.setPlatform(request.platform());
mapping.setPartCategory(category);
mapping.setNotes(request.notes());
AffiliateCategoryMap saved = categoryMappingRepository.save(mapping);
return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved));
}
// PUT /api/admin/category-mappings/{id}
@PutMapping("/{id}")
public PartRoleMappingDto update(
@PathVariable Integer id,
@RequestBody PartRoleMappingRequest request
) {
AffiliateCategoryMap mapping = categoryMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
if (request.platform() != null) {
mapping.setPlatform(request.platform());
}
if (request.partRole() != null) {
mapping.setSourceValue(request.partRole());
}
if (request.categorySlug() != null) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unknown category slug: " + request.categorySlug()
));
mapping.setPartCategory(category);
}
if (request.notes() != null) {
mapping.setNotes(request.notes());
}
AffiliateCategoryMap saved = categoryMappingRepository.save(mapping);
return toDto(saved);
}
// DELETE /api/admin/category-mappings/{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id) {
if (!categoryMappingRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
}
categoryMappingRepository.deleteById(id);
}
private PartRoleMappingDto toDto(AffiliateCategoryMap map) {
PartCategory cat = map.getPartCategory();
return new PartRoleMappingDto(
map.getId(),
map.getPlatform(),
map.getSourceValue(), // partRole
cat != null ? cat.getSlug() : null, // categorySlug
cat != null ? cat.getGroupName() : null,
map.getNotes()
);
}
}

View File

@@ -0,0 +1,35 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-categories")
@CrossOrigin // keep it loose for now, you can tighten origins later
public class PartCategoryAdminController {
private final PartCategoryRepository partCategories;
public PartCategoryAdminController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
}
}

View File

@@ -5,23 +5,32 @@ import jakarta.persistence.*;
@Entity @Entity
@Table(name = "affiliate_category_map") @Table(name = "affiliate_category_map")
public class AffiliateCategoryMap { public class AffiliateCategoryMap {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id; private Integer id;
@Column(name = "feedname", nullable = false, length = 100) // e.g. "PART_ROLE", "RAW_CATEGORY", etc.
private String feedname; @Column(name = "source_type", nullable = false)
private String sourceType;
@Column(name = "affiliatecategory", nullable = false) // the value were mapping from (e.g. "suppressor", "TRIGGER")
private String affiliatecategory; @Column(name = "source_value", nullable = false)
private String sourceValue;
@Column(name = "buildercategoryid", nullable = false) // optional platform ("AR-15", "PRECISION", etc.)
private Integer buildercategoryid; @Column(name = "platform")
private String platform;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id", nullable = false)
private PartCategory partCategory;
@Column(name = "notes") @Column(name = "notes")
private String notes; private String notes;
// --- getters / setters ---
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -30,28 +39,36 @@ public class AffiliateCategoryMap {
this.id = id; this.id = id;
} }
public String getFeedname() { public String getSourceType() {
return feedname; return sourceType;
} }
public void setFeedname(String feedname) { public void setSourceType(String sourceType) {
this.feedname = feedname; this.sourceType = sourceType;
} }
public String getAffiliatecategory() { public String getSourceValue() {
return affiliatecategory; return sourceValue;
} }
public void setAffiliatecategory(String affiliatecategory) { public void setSourceValue(String sourceValue) {
this.affiliatecategory = affiliatecategory; this.sourceValue = sourceValue;
} }
public Integer getBuildercategoryid() { public String getPlatform() {
return buildercategoryid; return platform;
} }
public void setBuildercategoryid(Integer buildercategoryid) { public void setPlatform(String platform) {
this.buildercategoryid = buildercategoryid; this.platform = platform;
}
public PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
} }
public String getNotes() { public String getNotes() {
@@ -61,5 +78,4 @@ public class AffiliateCategoryMap {
public void setNotes(String notes) { public void setNotes(String notes) {
this.notes = notes; this.notes = notes;
} }
} }

View File

@@ -1,24 +1,49 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity @Entity
@Table(name = "part_categories") @Table(name = "part_categories")
public class PartCategory { public class PartCategory {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
private Integer id; private Integer id;
@Column(name = "slug", nullable = false, length = Integer.MAX_VALUE) @Column(name = "slug", nullable = false, unique = true)
private String slug; private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "name", nullable = false)
private String name; private String name;
@Column(name = "description", length = Integer.MAX_VALUE) @Column(name = "description")
private String description; private String description;
@ColumnDefault("gen_random_uuid()")
@Column(name = "uuid", nullable = false)
private UUID uuid;
@Column(name = "group_name")
private String groupName;
@Column(name = "sort_order")
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// --- Getters & Setters ---
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -51,4 +76,43 @@ public class PartCategory {
this.description = description; this.description = description;
} }
public UUID getUuid() {
return uuid;
}
public void setUuid(UUID uuid) {
this.uuid = uuid;
}
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
} }

View File

@@ -0,0 +1,56 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "part_role_category_mappings",
uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"}))
public class PartRoleCategoryMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "platform", nullable = false)
private String platform;
@Column(name = "part_role", nullable = false)
private String partRole;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false)
private PartCategory category;
@Column(name = "notes")
private String notes;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// getters/setters…
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getPartRole() { return partRole; }
public void setPartRole(String partRole) { this.partRole = partRole; }
public PartCategory getCategory() { return category; }
public void setCategory(PartCategory category) { this.category = category; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -3,7 +3,13 @@ package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
import group.goforward.ballistic.model.ProductOffer;
import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@@ -68,7 +74,16 @@ public class Product {
@Column(name = "platform_locked", nullable = false) @Column(name = "platform_locked", nullable = false)
private Boolean platformLocked = false; private Boolean platformLocked = false;
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
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 ---
@@ -236,4 +251,41 @@ public class Product {
public void setConfiguration(ProductConfiguration configuration) { public void setConfiguration(ProductConfiguration configuration) {
this.configuration = configuration; this.configuration = configuration;
} }
// Convenience: best offer price for Gunbuilder
public BigDecimal getBestOfferPrice() {
if (offers == null || offers.isEmpty()) {
return BigDecimal.ZERO;
}
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);
}
// Convenience: URL for the best-priced offer
public String getBestOfferBuyUrl() {
if (offers == null || offers.isEmpty()) {
return null;
}
return offers.stream()
.sorted(Comparator.comparing(offer -> {
if (offer.getSalePrice() != null) {
return offer.getSalePrice();
}
return offer.getRetailPrice();
}, Comparator.nullsLast(BigDecimal::compareTo)))
.map(ProductOffer::getAffiliateUrl)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
} }

View File

@@ -7,11 +7,11 @@ import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID;
@Entity @Entity
@Table(name = "product_offers") @Table(name = "product_offers")
public class ProductOffer { public class ProductOffer {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
@@ -26,16 +26,16 @@ public class ProductOffer {
@JoinColumn(name = "merchant_id", nullable = false) @JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant; private Merchant merchant;
@Column(name = "avantlink_product_id", nullable = false, length = Integer.MAX_VALUE) @Column(name = "avantlink_product_id", nullable = false)
private String avantlinkProductId; private String avantlinkProductId;
@Column(name = "sku", length = Integer.MAX_VALUE) @Column(name = "sku")
private String sku; private String sku;
@Column(name = "upc", length = Integer.MAX_VALUE) @Column(name = "upc")
private String upc; private String upc;
@Column(name = "buy_url", nullable = false, length = Integer.MAX_VALUE) @Column(name = "buy_url", nullable = false)
private String buyUrl; private String buyUrl;
@Column(name = "price", nullable = false, precision = 10, scale = 2) @Column(name = "price", nullable = false, precision = 10, scale = 2)
@@ -45,7 +45,7 @@ public class ProductOffer {
private BigDecimal originalPrice; private BigDecimal originalPrice;
@ColumnDefault("'USD'") @ColumnDefault("'USD'")
@Column(name = "currency", nullable = false, length = Integer.MAX_VALUE) @Column(name = "currency", nullable = false)
private String currency; private String currency;
@ColumnDefault("true") @ColumnDefault("true")
@@ -60,6 +60,10 @@ public class ProductOffer {
@Column(name = "first_seen_at", nullable = false) @Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt; private OffsetDateTime firstSeenAt;
// -----------------------------------------------------
// Getters & setters
// -----------------------------------------------------
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -164,14 +168,26 @@ public class ProductOffer {
this.firstSeenAt = firstSeenAt; this.firstSeenAt = firstSeenAt;
} }
// -----------------------------------------------------
// Helper Methods (used by Product entity)
// -----------------------------------------------------
public BigDecimal getSalePrice() {
return price;
}
public BigDecimal getRetailPrice() {
return originalPrice != null ? originalPrice : price;
}
public String getAffiliateUrl() {
return buyUrl;
}
public BigDecimal getEffectivePrice() { public BigDecimal getEffectivePrice() {
// Prefer a true sale price when it's lower than the original
if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) { if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) {
return price; return price;
} }
// Otherwise, use whatever is available
return price != null ? price : originalPrice; return price != null ? price : originalPrice;
} }
} }

View File

@@ -1,7 +1,29 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.AffiliateCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> { import java.util.List;
import java.util.Optional;
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> {
// Match by source_type + source_value + platform (case-insensitive)
Optional<AffiliateCategoryMap> findBySourceTypeAndSourceValueAndPlatformIgnoreCase(
String sourceType,
String sourceValue,
String platform
);
// Fallback: match by source_type + source_value when platform is null/ignored
Optional<AffiliateCategoryMap> findBySourceTypeAndSourceValueIgnoreCase(
String sourceType,
String sourceValue
);
// Used by AdminCategoryMappingController: list mappings for a given source_type + platform
List<AffiliateCategoryMap> findBySourceTypeAndPlatformOrderById(
String sourceType,
String platform
);
} }

View File

@@ -1,9 +1,14 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartCategory; import group.goforward.ballistic.model.PartCategory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> { import java.util.Optional;
Optional<PartCategory> findBySlug(String slug);
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
Optional<PartCategory> findBySlug(String slug);
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
} }

View File

@@ -0,0 +1,14 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartRoleCategoryMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartRoleCategoryMappingRepository extends JpaRepository<PartRoleCategoryMapping, Integer> {
List<PartRoleCategoryMapping> findAllByPlatformOrderByPartRoleAsc(String platform);
Optional<PartRoleCategoryMapping> findByPlatformAndPartRole(String platform, String partRole);
}

View File

@@ -1,53 +1,62 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.Brand; 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;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Optional; import java.util.List;
import java.util.UUID;
import java.util.List; public interface ProductRepository extends JpaRepository<Product, Integer> {
import java.util.Collection;
// -------------------------------------------------
public interface ProductRepository extends JpaRepository<Product, Integer> { // Used by MerchantFeedImportServiceImpl
// -------------------------------------------------
Optional<Product> findByUuid(UUID uuid);
List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
boolean existsBySlug(String slug);
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
boolean existsBySlug(String slug);
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
// -------------------------------------------------
// All products for a given platform (e.g. "AR-15") // Used by ProductController for platform views
List<Product> findByPlatform(String platform); // -------------------------------------------------
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) @Query("""
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles); SELECT p
FROM Product p
// ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- JOIN FETCH p.brand b
WHERE p.platform = :platform
@Query(""" AND p.deletedAt IS NULL
SELECT p """)
FROM Product p List<Product> findByPlatformWithBrand(@Param("platform") String platform);
JOIN FETCH p.brand b
WHERE p.platform = :platform @Query("""
AND p.deletedAt IS NULL SELECT p
""") FROM Product p
List<Product> findByPlatformWithBrand(@Param("platform") String platform); JOIN FETCH p.brand b
WHERE p.platform = :platform
@Query(""" AND p.partRole IN :roles
SELECT p AND p.deletedAt IS NULL
FROM Product p """)
JOIN FETCH p.brand b List<Product> findByPlatformAndPartRoleInWithBrand(
WHERE p.platform = :platform @Param("platform") String platform,
AND p.partRole IN :partRoles @Param("roles") List<String> roles
AND p.deletedAt IS NULL );
""")
List<Product> findByPlatformAndPartRoleInWithBrand( // -------------------------------------------------
@Param("platform") String platform, // Used by Gunbuilder service (if you wired this)
@Param("partRoles") Collection<String> partRoles // -------------------------------------------------
);
@Query("""
SELECT DISTINCT p
FROM Product p
LEFT JOIN FETCH p.brand b
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
List<Product> findSomethingForGunbuilder(@Param("platform") String platform);
} }

View File

@@ -0,0 +1,57 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.GunbuilderProductDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class GunbuilderProductService {
private final ProductRepository productRepository;
private final PartCategoryResolverService partCategoryResolverService;
public GunbuilderProductService(
ProductRepository productRepository,
PartCategoryResolverService partCategoryResolverService
) {
this.productRepository = productRepository;
this.partCategoryResolverService = partCategoryResolverService;
}
public List<GunbuilderProductDto> listGunbuilderProducts(String platform) {
List<Product> products = productRepository.findSomethingForGunbuilder(platform);
return products.stream()
.map(p -> {
var maybeCategory = partCategoryResolverService
.resolveForPlatformAndPartRole(platform, p.getPartRole());
if (maybeCategory.isEmpty()) {
// you can also log here
return null;
}
PartCategory cat = maybeCategory.get();
return new GunbuilderProductDto(
p.getId(),
p.getName(),
p.getBrand().getName(),
platform,
p.getPartRole(),
p.getBestOfferPrice(),
p.getMainImageUrl(),
p.getBestOfferBuyUrl(),
cat.getSlug(),
cat.getGroupName()
);
})
.filter(dto -> dto != null)
.toList();
}
}

View File

@@ -0,0 +1,38 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.AffiliateCategoryMap;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.CategoryMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class PartCategoryResolverService {
private final CategoryMappingRepository categoryMappingRepository;
public PartCategoryResolverService(CategoryMappingRepository categoryMappingRepository) {
this.categoryMappingRepository = categoryMappingRepository;
}
/**
* Resolve a part category from a platform + partRole (what gunbuilder cares about).
* Returns Optional.empty() if we have no mapping yet.
*/
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
// sourceType is a convention you can also enum this
String sourceType = "PART_ROLE";
// First try with platform
return categoryMappingRepository
.findBySourceTypeAndSourceValueAndPlatformIgnoreCase(sourceType, partRole, platform)
.map(AffiliateCategoryMap::getPartCategory)
// if that fails, fall back to ANY platform
.or(() ->
categoryMappingRepository
.findBySourceTypeAndSourceValueIgnoreCase(sourceType, partRole)
.map(AffiliateCategoryMap::getPartCategory)
);
}
}

View File

@@ -0,0 +1,53 @@
package group.goforward.ballistic.web.dto;
import java.math.BigDecimal;
public class GunbuilderProductDto {
private Integer id;
private String name;
private String brand;
private String platform;
private String partRole;
private BigDecimal price;
private String imageUrl;
private String buyUrl;
private String categorySlug;
private String categoryGroup;
public GunbuilderProductDto(
Integer id,
String name,
String brand,
String platform,
String partRole,
BigDecimal price,
String imageUrl,
String buyUrl,
String categorySlug,
String categoryGroup
) {
this.id = id;
this.name = name;
this.brand = brand;
this.platform = platform;
this.partRole = partRole;
this.price = price;
this.imageUrl = imageUrl;
this.buyUrl = buyUrl;
this.categorySlug = categorySlug;
this.categoryGroup = categoryGroup;
}
// --- Getters only (DTOs are read-only in most cases) ---
public Integer getId() { return id; }
public String getName() { return name; }
public String getBrand() { return brand; }
public String getPlatform() { return platform; }
public String getPartRole() { return partRole; }
public BigDecimal getPrice() { return price; }
public String getImageUrl() { return imageUrl; }
public String getBuyUrl() { return buyUrl; }
public String getCategorySlug() { return categorySlug; }
public String getCategoryGroup() { return categoryGroup; }
}

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record PartCategoryDto(
Integer id,
String slug,
String name,
String description,
String groupName,
Integer sortOrder
) {}

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record PartRoleMappingDto(
Integer id,
String platform,
String partRole,
String categorySlug,
String groupName,
String notes
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record PartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}