From b185abe28ddf8632b8eb0f4acd5d8ba84c6fe442 Mon Sep 17 00:00:00 2001
From: Sean
Date: Sat, 6 Dec 2025 20:06:10 -0500
Subject: [PATCH 1/5] fixing part_role mapping
---
.../admin/AdminPartRoleMappingController.java | 3 +-
.../ballistic/model/PartRoleMapping.java | 44 +++++++-
.../repos/PartRoleMappingRepository.java | 24 +++-
.../ballistic/repos/ProductRepository.java | 27 ++++-
.../services/GunbuilderProductService.java | 106 ++++++++++++++++--
.../services/PartCategoryResolverService.java | 35 +++---
.../services/PartRoleMappingService.java | 35 ++++++
.../web/GunbuilderProductController.java | 84 ++++++++++++++
.../web/PartRoleMappingController.java | 31 +++++
.../web/dto/PartRoleToCategoryDto.java | 7 ++
.../web/dto/admin/PartRoleMappingDto.java | 5 +-
.../web/mapper/PartRoleMappingMapper.java | 37 ++++++
src/main/resources/application.properties | 11 +-
13 files changed, 404 insertions(+), 45 deletions(-)
create mode 100644 src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java
create mode 100644 src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java
create mode 100644 src/main/java/group/goforward/ballistic/web/PartRoleMappingController.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/PartRoleToCategoryDto.java
create mode 100644 src/main/java/group/goforward/ballistic/web/mapper/PartRoleMappingMapper.java
diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java
index 7300129..5b79c38 100644
--- a/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java
+++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminPartRoleMappingController.java
@@ -37,7 +37,8 @@ public class AdminPartRoleMappingController {
List mappings;
if (platform != null && !platform.isBlank()) {
- mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform);
+ mappings = partRoleMappingRepository
+ .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
} else {
mappings = partRoleMappingRepository.findAll();
}
diff --git a/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java
index d336815..4d23d7e 100644
--- a/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java
+++ b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java
@@ -1,6 +1,7 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
+import java.time.OffsetDateTime;
@Entity
@Table(name = "part_role_mappings")
@@ -14,15 +15,38 @@ public class PartRoleMapping {
private String platform; // e.g. "AR-15"
@Column(name = "part_role", nullable = false)
- private String partRole; // e.g. "UPPER", "BARREL", etc.
+ private String partRole; // e.g. "LOWER_RECEIVER_STRIPPED"
- @ManyToOne(optional = false)
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(columnDefinition = "text")
private String notes;
+ @Column(name = "created_at", updatable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "updated_at")
+ private OffsetDateTime updatedAt;
+
+ @Column(name = "deleted_at")
+ private OffsetDateTime deletedAt;
+
+ @PrePersist
+ protected void onCreate() {
+ OffsetDateTime now = OffsetDateTime.now();
+ this.createdAt = now;
+ this.updatedAt = now;
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ this.updatedAt = OffsetDateTime.now();
+ }
+
+ // getters/setters
+
public Integer getId() {
return id;
}
@@ -62,4 +86,20 @@ public class PartRoleMapping {
public void setNotes(String notes) {
this.notes = notes;
}
+
+ public OffsetDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public OffsetDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public OffsetDateTime getDeletedAt() {
+ return deletedAt;
+ }
+
+ public void setDeletedAt(OffsetDateTime deletedAt) {
+ this.deletedAt = deletedAt;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java
index b64889e..91b7eb9 100644
--- a/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java
@@ -4,9 +4,29 @@ import group.goforward.ballistic.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
+import java.util.Optional;
public interface PartRoleMappingRepository extends JpaRepository {
- // List mappings for a platform, ordered nicely for the UI
- List findByPlatformOrderByPartRoleAsc(String platform);
+ // For resolver: one mapping per platform + partRole
+ Optional findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
+ String platform,
+ String partRole
+ );
+
+ // Optional: debug / inspection
+ List findAllByPlatformAndPartRoleAndDeletedAtIsNull(
+ String platform,
+ String partRole
+ );
+
+ // This is the one PartRoleMappingService needs
+ List findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(
+ String platform
+ );
+
+ List findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
+ String platform,
+ String slug
+ );
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
index ad91c1e..1801d49 100644
--- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import java.util.Collection;
import java.util.List;
public interface ProductRepository extends JpaRepository {
@@ -33,8 +34,8 @@ public interface ProductRepository extends JpaRepository {
""")
List findByPlatformWithBrand(@Param("platform") String platform);
-@Query(name="Products.findByPlatformWithBrand")
-List findByPlatformWithBrandNQ(@Param("platform") String platform);
+ @Query(name = "Products.findByPlatformWithBrand")
+ List findByPlatformWithBrandNQ(@Param("platform") String platform);
@Query("""
SELECT p
@@ -50,7 +51,21 @@ List findByPlatformWithBrandNQ(@Param("platform") String platform);
);
// -------------------------------------------------
- // Used by Gunbuilder service (if you wired this)
+ // Used by /api/gunbuilder/test-products-db
+ // -------------------------------------------------
+
+ @Query("""
+ SELECT p
+ FROM Product p
+ JOIN FETCH p.brand b
+ WHERE p.platform = :platform
+ AND p.deletedAt IS NULL
+ ORDER BY p.id
+ """)
+ List findTop5ByPlatformWithBrand(@Param("platform") String platform);
+
+ // -------------------------------------------------
+ // Used by GunbuilderProductService
// -------------------------------------------------
@Query("""
@@ -59,7 +74,11 @@ List findByPlatformWithBrandNQ(@Param("platform") String platform);
LEFT JOIN FETCH p.brand b
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
+ AND p.partRole IN :partRoles
AND p.deletedAt IS NULL
""")
- List findSomethingForGunbuilder(@Param("platform") String platform);
+ List findForGunbuilderByPlatformAndPartRoles(
+ @Param("platform") String platform,
+ @Param("partRoles") Collection partRoles
+ );
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java
index 1e074fe..5c57802 100644
--- a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java
+++ b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java
@@ -5,38 +5,77 @@ 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 group.goforward.ballistic.model.PartRoleMapping;
+import group.goforward.ballistic.repos.PartRoleMappingRepository;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
import java.util.List;
+
@Service
public class GunbuilderProductService {
private final ProductRepository productRepository;
private final PartCategoryResolverService partCategoryResolverService;
+ private final PartRoleMappingRepository partRoleMappingRepository;
public GunbuilderProductService(
ProductRepository productRepository,
- PartCategoryResolverService partCategoryResolverService
+ PartCategoryResolverService partCategoryResolverService,
+ PartRoleMappingRepository partRoleMappingRepository
) {
this.productRepository = productRepository;
this.partCategoryResolverService = partCategoryResolverService;
+ this.partRoleMappingRepository = partRoleMappingRepository;
}
- public List listGunbuilderProducts(String platform) {
+ /**
+ * Main builder endpoint.
+ * For now we ONLY support calls that provide partRoles,
+ * to avoid pulling the entire catalog into memory.
+ */
+ public List listGunbuilderProducts(String platform, List partRoles) {
- List products = productRepository.findSomethingForGunbuilder(platform);
+ System.out.println(">>> GB: listGunbuilderProducts platform=" + platform
+ + ", partRoles=" + partRoles);
+
+ if (partRoles == null || partRoles.isEmpty()) {
+ System.out.println(">>> GB: no partRoles provided, returning empty list");
+ return List.of();
+ }
+
+ List products =
+ productRepository.findForGunbuilderByPlatformAndPartRoles(platform, partRoles);
+
+ System.out.println(">>> GB: repo returned " + products.size() + " products");
+
+ Map categoryCache = new HashMap<>();
return products.stream()
.map(p -> {
- var maybeCategory = partCategoryResolverService
- .resolveForPlatformAndPartRole(platform, p.getPartRole());
+ PartCategory cat = categoryCache.computeIfAbsent(
+ p.getPartRole(),
+ role -> partCategoryResolverService
+ .resolveForPlatformAndPartRole(platform, role)
+ .orElse(null)
+ );
- if (maybeCategory.isEmpty()) {
- // you can also log here
- return null;
+ if (cat == null) {
+ System.out.println(">>> GB: NO CATEGORY for platform=" + platform
+ + ", partRole=" + p.getPartRole()
+ + ", productId=" + p.getId());
+ } else {
+ System.out.println(">>> GB: CATEGORY for productId=" + p.getId()
+ + " -> slug=" + cat.getSlug()
+ + ", group=" + cat.getGroupName());
}
- PartCategory cat = maybeCategory.get();
+ // TEMP: do NOT drop products if category is null.
+ // Just mark them as "unmapped" so we can see them in the JSON.
+ String categorySlug = (cat != null) ? cat.getSlug() : "unmapped";
+ String categoryGroup = (cat != null) ? cat.getGroupName() : "Unmapped";
return new GunbuilderProductDto(
p.getId(),
@@ -47,11 +86,54 @@ public class GunbuilderProductService {
p.getBestOfferPrice(),
p.getMainImageUrl(),
p.getBestOfferBuyUrl(),
- cat.getSlug(),
- cat.getGroupName()
+ categorySlug,
+ categoryGroup
);
})
- .filter(dto -> dto != null)
.toList();
}
+
+ public List listGunbuilderProductsByCategory(
+ String platform,
+ String categorySlug
+ ) {
+ System.out.println(">>> GB: listGunbuilderProductsByCategory platform=" + platform
+ + ", categorySlug=" + categorySlug);
+
+ if (platform == null || platform.isBlank()
+ || categorySlug == null || categorySlug.isBlank()) {
+ System.out.println(">>> GB: missing platform or categorySlug, returning empty list");
+ return List.of();
+ }
+
+ List mappings =
+ partRoleMappingRepository.findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
+ platform,
+ categorySlug
+ );
+
+ List partRoles = mappings.stream()
+ .map(PartRoleMapping::getPartRole)
+ .distinct()
+ .toList();
+
+ System.out.println(">>> GB: resolved " + partRoles.size()
+ + " partRoles for categorySlug=" + categorySlug + " -> " + partRoles);
+
+ if (partRoles.isEmpty()) {
+ return List.of();
+ }
+
+ // Reuse the existing method that already does all the DTO + category resolution logic
+ return listGunbuilderProducts(platform, partRoles);
+ }
+
+ /**
+ * Tiny helper used ONLY by /test-products-db to prove DB wiring.
+ */
+ public List getSampleProducts(String platform) {
+ // You already have this wired via ProductRepository.findTop5ByPlatformWithBrand
+ // If that method exists, keep using it; if not, you can stub a tiny query here.
+ return productRepository.findTop5ByPlatformWithBrand(platform);
+ }
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java
index 31dd63a..12e0f52 100644
--- a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java
+++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java
@@ -1,7 +1,8 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.PartCategory;
-import group.goforward.ballistic.repos.PartCategoryRepository;
+import group.goforward.ballistic.model.PartRoleMapping;
+import group.goforward.ballistic.repos.PartRoleMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@@ -9,33 +10,29 @@ import java.util.Optional;
@Service
public class PartCategoryResolverService {
- private final PartCategoryRepository partCategoryRepository;
+ private final PartRoleMappingRepository partRoleMappingRepository;
- public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) {
- this.partCategoryRepository = partCategoryRepository;
+ public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
+ this.partRoleMappingRepository = partRoleMappingRepository;
}
/**
- * Resolve the canonical PartCategory for a given platform + partRole.
- *
- * For now we keep it simple:
- * - We treat partRole as the slug (e.g. "barrel", "upper", "trigger").
- * - Normalize to lower-kebab (spaces -> dashes, lowercased).
- * - Look up by slug in part_categories.
- *
- * Later, if we want per-merchant / per-platform overrides using category_mappings,
- * we can extend this method without changing callers.
+ * Resolve a PartCategory for a given platform + partRole.
+ * Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
*/
public Optional resolveForPlatformAndPartRole(String platform, String partRole) {
- if (partRole == null || partRole.isBlank()) {
+
+ if (platform == null || partRole == null) {
return Optional.empty();
}
- String normalizedSlug = partRole
- .trim()
- .toLowerCase()
- .replace(" ", "-");
+ // Keep things case-sensitive since your DB values are already uppercase.
+ Optional mappingOpt =
+ partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
+ platform,
+ partRole
+ );
- return partCategoryRepository.findBySlug(normalizedSlug);
+ return mappingOpt.map(PartRoleMapping::getPartCategory);
}
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java b/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java
new file mode 100644
index 0000000..cd9f507
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/PartRoleMappingService.java
@@ -0,0 +1,35 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.repos.PartRoleMappingRepository;
+import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto;
+import group.goforward.ballistic.web.dto.PartRoleToCategoryDto;
+import group.goforward.ballistic.web.mapper.PartRoleMappingMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class PartRoleMappingService {
+
+ private final PartRoleMappingRepository repository;
+
+ public PartRoleMappingService(PartRoleMappingRepository repository) {
+ this.repository = repository;
+ }
+
+ public List getMappingsForPlatform(String platform) {
+ return repository
+ .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
+ .stream()
+ .map(PartRoleMappingMapper::toDto)
+ .toList();
+ }
+
+ public List getRoleToCategoryMap(String platform) {
+ return repository
+ .findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
+ .stream()
+ .map(PartRoleMappingMapper::toRoleMapDto)
+ .toList();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java b/src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java
new file mode 100644
index 0000000..4fbd505
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/web/GunbuilderProductController.java
@@ -0,0 +1,84 @@
+package group.goforward.ballistic.web;
+
+import group.goforward.ballistic.services.GunbuilderProductService;
+import group.goforward.ballistic.web.dto.GunbuilderProductDto;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/gunbuilder")
+public class GunbuilderProductController {
+
+ private final GunbuilderProductService gunbuilderProductService;
+
+ public GunbuilderProductController(GunbuilderProductService gunbuilderProductService) {
+ this.gunbuilderProductService = gunbuilderProductService;
+ System.out.println(">>> GunbuilderProductController initialized");
+ }
+
+ // 🔹 sanity check: is this controller even mapped?
+ @GetMapping("/ping")
+ public Map ping() {
+ return Map.of("status", "ok", "source", "GunbuilderProductController");
+ }
+
+ // 🔹 super-dumb test: no DB, no service, just prove the route works
+ @GetMapping("/test-products")
+ public Map testProducts(@RequestParam String platform) {
+ System.out.println(">>> /api/gunbuilder/test-products hit for platform=" + platform);
+
+ Map m = new java.util.HashMap<>();
+ m.put("platform", platform);
+ m.put("note", "test endpoint only");
+ m.put("ok", true);
+ return m;
+ }
+
+ /**
+ * List products for the builder UI.
+ *
+ * Examples:
+ * GET /api/gunbuilder/products?platform=AR-15
+ * GET /api/gunbuilder/products?platform=AR-15&partRoles=LOWER_RECEIVER_STRIPPED&partRoles=LOWER_RECEIVER_COMPLETE
+ */
+ @GetMapping("/products")
+ public List listProducts(
+ @RequestParam String platform,
+ @RequestParam(required = false) List partRoles
+ ) {
+ return gunbuilderProductService.listGunbuilderProducts(platform, partRoles);
+ }
+
+ @GetMapping("/products/by-category")
+ public List listProductsByCategory(
+ @RequestParam String platform,
+ @RequestParam String categorySlug
+ ) {
+ return gunbuilderProductService.listGunbuilderProductsByCategory(platform, categorySlug);
+ }
+
+ // 🔹 DB test: hit repo via service and return a tiny view of products
+ @GetMapping("/test-products-db")
+ public List
*
* @since 1.0
* @author Don Strawsburg
diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java
index c49339c..dda4a80 100644
--- a/src/main/java/group/goforward/ballistic/controllers/package-info.java
+++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java
@@ -4,7 +4,7 @@
*
*
* The main entry point for managing the inventory is the
- * {@link group.goforward.ballistic.BallisticApplication} class.
+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.
*
* @since 1.0
* @author Don Strawsburg
diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java
index 586776b..b3a6e59 100644
--- a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java
+++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java
@@ -4,7 +4,7 @@
*
*
* The main entry point for managing the inventory is the
- * {@link group.goforward.ballistic.BallisticApplication} class.
+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.
*
* @since 1.0
* @author Sean Strawsburg
diff --git a/src/main/java/group/goforward/ballistic/repos/package-info.java b/src/main/java/group/goforward/ballistic/repos/package-info.java
index c278bd2..e068668 100644
--- a/src/main/java/group/goforward/ballistic/repos/package-info.java
+++ b/src/main/java/group/goforward/ballistic/repos/package-info.java
@@ -4,7 +4,7 @@
*
*
* The main entry point for managing the inventory is the
- * {@link group.goforward.ballistic.BallisticApplication} class.
+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.
*
* @since 1.0
* @author Sean Strawsburg
diff --git a/src/main/java/group/goforward/ballistic/services/impl/package-info.java b/src/main/java/group/goforward/ballistic/services/impl/package-info.java
index e6d7a02..a443585 100644
--- a/src/main/java/group/goforward/ballistic/services/impl/package-info.java
+++ b/src/main/java/group/goforward/ballistic/services/impl/package-info.java
@@ -4,7 +4,7 @@
*
*
* The main entry point for managing the inventory is the
- * {@link group.goforward.ballistic.BallisticApplication} class.
+ * {@link group.goforward.ballistic.BattlBuilderApplication} class.
*
* @since 1.0
* @author Don Strawsburg
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 06afe4b..de2c898 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,4 +1,4 @@
-spring.application.name=ballistic
+spring.application.name=BattlBuilderAPI
# Database connection properties
spring.datasource.url=jdbc:postgresql://r710.gofwd.group:5433/ss_builder
spring.datasource.username=postgres
@@ -16,4 +16,6 @@ security.jwt.access-token-minutes=2880
# Temp disabling logging to find what I fucked up
spring.jpa.show-sql=false
logging.level.org.hibernate.SQL=warn
-logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn
\ No newline at end of file
+logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn
+
+
From 58a6c671acda545e9a9fad0e622619c790a78bd7 Mon Sep 17 00:00:00 2001
From: Sean
Date: Sun, 7 Dec 2025 09:58:30 -0500
Subject: [PATCH 3/5] updating category/role mapping. new endpoints for
unmapped parts and apis for front end
---
.../ballistic/model/ImportStatus.java | 7 +
.../goforward/ballistic/model/Product.java | 259 ++++-------
.../MerchantCategoryMappingRepository.java | 11 +
.../repos/ProductOfferRepository.java | 12 +
.../ballistic/repos/ProductRepository.java | 95 +++-
.../CategoryMappingRecommendationService.java | 72 +++
.../services/GunbuilderProductService.java | 7 +-
.../services/ImportStatusAdminService.java | 42 ++
.../services/MappingAdminService.java | 83 ++++
.../MerchantCategoryMappingService.java | 1 -
.../impl/MerchantFeedImportServiceImpl.java | 416 +++++++++---------
.../admin/CategoryMappingAdminController.java | 40 ++
.../admin/ImportStatusAdminController.java | 32 ++
.../dto/CategoryMappingRecommendationDto.java | 10 +
.../web/dto/ImportStatusByMerchantDto.java | 11 +
.../web/dto/ImportStatusSummaryDto.java | 9 +
.../web/dto/PendingMappingBucketDto.java | 9 +
.../web/dto/admin/AdminMappingController.java | 35 ++
18 files changed, 764 insertions(+), 387 deletions(-)
create mode 100644 src/main/java/group/goforward/ballistic/model/ImportStatus.java
create mode 100644 src/main/java/group/goforward/ballistic/services/CategoryMappingRecommendationService.java
create mode 100644 src/main/java/group/goforward/ballistic/services/ImportStatusAdminService.java
create mode 100644 src/main/java/group/goforward/ballistic/services/MappingAdminService.java
create mode 100644 src/main/java/group/goforward/ballistic/web/admin/CategoryMappingAdminController.java
create mode 100644 src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/CategoryMappingRecommendationDto.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ImportStatusByMerchantDto.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/ImportStatusSummaryDto.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/PendingMappingBucketDto.java
create mode 100644 src/main/java/group/goforward/ballistic/web/dto/admin/AdminMappingController.java
diff --git a/src/main/java/group/goforward/ballistic/model/ImportStatus.java b/src/main/java/group/goforward/ballistic/model/ImportStatus.java
new file mode 100644
index 0000000..d4a6b64
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/ImportStatus.java
@@ -0,0 +1,7 @@
+package group.goforward.ballistic.model;
+
+public enum ImportStatus {
+ PENDING_MAPPING, // Ingested but not fully mapped / trusted
+ MAPPED, // Clean + mapped + safe for builder
+ REJECTED // Junk / not relevant / explicitly excluded
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java
index 816099f..8e482ae 100644
--- a/src/main/java/group/goforward/ballistic/model/Product.java
+++ b/src/main/java/group/goforward/ballistic/model/Product.java
@@ -9,22 +9,19 @@ import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
-import group.goforward.ballistic.model.ProductOffer;
-import group.goforward.ballistic.model.ProductConfiguration;
-
@Entity
@Table(name = "products")
@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
- "SELECT p FROM Product p" +
- " JOIN FETCH p.brand b" +
- " WHERE p.platform = :platform" +
- " AND p.deletedAt IS NULL")
+ "SELECT p FROM Product p" +
+ " JOIN FETCH p.brand b" +
+ " WHERE p.platform = :platform" +
+ " AND p.deletedAt IS NULL")
@NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" +
- "SELECT p FROM Product p JOIN FETCH p.brand b" +
- " WHERE p.platform = :platform" +
- " AND p.partRole IN :roles" +
- " AND p.deletedAt IS NULL")
+ "SELECT p FROM Product p JOIN FETCH p.brand b" +
+ " WHERE p.platform = :platform" +
+ " AND p.partRole IN :roles" +
+ " AND p.deletedAt IS NULL")
@NamedQuery(name="Product.findProductsbyBrandByOffers", query="" +
" SELECT DISTINCT p FROM Product p" +
@@ -32,11 +29,10 @@ import group.goforward.ballistic.model.ProductConfiguration;
" LEFT JOIN FETCH p.offers o" +
" WHERE p.platform = :platform" +
" AND p.deletedAt IS NULL")
-
public class Product {
@Id
- @GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id;
@@ -86,38 +82,27 @@ public class Product {
@Column(name = "deleted_at")
private Instant deletedAt;
-
+
@Column(name = "raw_category_key")
private String rawCategoryKey;
@Column(name = "platform_locked", nullable = false)
private Boolean platformLocked = false;
+ @Enumerated(EnumType.STRING)
+ @Column(name = "import_status", nullable = false)
+ private ImportStatus importStatus = ImportStatus.MAPPED;
+
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
private Set offers = new HashSet<>();
- public Set getOffers() {
- return offers;
- }
-
- public void setOffers(Set offers) {
- this.offers = offers;
- }
-
// --- lifecycle hooks ---
-
@PrePersist
public void prePersist() {
- if (uuid == null) {
- uuid = UUID.randomUUID();
- }
+ if (uuid == null) uuid = UUID.randomUUID();
Instant now = Instant.now();
- if (createdAt == null) {
- createdAt = now;
- }
- if (updatedAt == null) {
- updatedAt = now;
- }
+ if (createdAt == null) createdAt = now;
+ if (updatedAt == null) updatedAt = now;
}
@PreUpdate
@@ -125,181 +110,101 @@ public class Product {
updatedAt = Instant.now();
}
- public String getRawCategoryKey() {
- return rawCategoryKey;
- }
-
- public void setRawCategoryKey(String rawCategoryKey) {
- this.rawCategoryKey = rawCategoryKey;
- }
-
// --- getters & setters ---
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
- public Integer getId() {
- return id;
- }
-
- public void setId(Integer id) {
- this.id = id;
- }
-
- public UUID getUuid() {
- return uuid;
- }
-
- public void setUuid(UUID uuid) {
- this.uuid = uuid;
- }
-
- public Brand getBrand() {
- return brand;
- }
-
- public void setBrand(Brand brand) {
- this.brand = brand;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getSlug() {
- return slug;
- }
-
- public void setSlug(String slug) {
- this.slug = slug;
- }
-
- public String getMpn() {
- return mpn;
- }
-
- public void setMpn(String mpn) {
- this.mpn = mpn;
- }
-
- public String getUpc() {
- return upc;
- }
-
- public void setUpc(String upc) {
- this.upc = upc;
- }
-
- public String getPlatform() {
- return platform;
- }
-
- public void setPlatform(String platform) {
- this.platform = platform;
- }
-
- public String getPartRole() {
- return partRole;
- }
-
- public void setPartRole(String partRole) {
- this.partRole = partRole;
- }
-
- public String getShortDescription() {
- return shortDescription;
+ public UUID getUuid() { return uuid; }
+ public void setUuid(UUID uuid) { this.uuid = uuid; }
+
+ public Brand getBrand() { return brand; }
+ public void setBrand(Brand brand) { this.brand = brand; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getSlug() { return slug; }
+ public void setSlug(String slug) { this.slug = slug; }
+
+ public String getMpn() { return mpn; }
+ public void setMpn(String mpn) { this.mpn = mpn; }
+
+ public String getUpc() { return upc; }
+ public void setUpc(String upc) { this.upc = upc; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getPartRole() { return partRole; }
+ public void setPartRole(String partRole) { this.partRole = partRole; }
+
+ public ProductConfiguration getConfiguration() { return configuration; }
+ public void setConfiguration(ProductConfiguration configuration) {
+ this.configuration = configuration;
}
+ public String getShortDescription() { return shortDescription; }
public void setShortDescription(String shortDescription) {
this.shortDescription = shortDescription;
}
- public String getDescription() {
- return description;
- }
-
+ public String getDescription() { return description; }
public void setDescription(String description) {
this.description = description;
}
- public String getMainImageUrl() {
- return mainImageUrl;
- }
-
+ public String getMainImageUrl() { return mainImageUrl; }
public void setMainImageUrl(String mainImageUrl) {
this.mainImageUrl = mainImageUrl;
}
- public Instant getCreatedAt() {
- return createdAt;
- }
+ public Instant getCreatedAt() { return createdAt; }
+ public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
- public void setCreatedAt(Instant createdAt) {
- this.createdAt = createdAt;
- }
+ public Instant getUpdatedAt() { return updatedAt; }
+ public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
- public Instant getUpdatedAt() {
- return updatedAt;
- }
-
- public void setUpdatedAt(Instant updatedAt) {
- this.updatedAt = updatedAt;
- }
-
- public Instant getDeletedAt() {
- return deletedAt;
- }
-
- public void setDeletedAt(Instant deletedAt) {
- this.deletedAt = deletedAt;
- }
-
- public Boolean getPlatformLocked() {
- return platformLocked;
- }
+ public Instant getDeletedAt() { return deletedAt; }
+ public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
+ public Boolean getPlatformLocked() { return platformLocked; }
public void setPlatformLocked(Boolean platformLocked) {
this.platformLocked = platformLocked;
}
-
- public ProductConfiguration getConfiguration() {
- return configuration;
+
+ public String getRawCategoryKey() { return rawCategoryKey; }
+ public void setRawCategoryKey(String rawCategoryKey) {
+ this.rawCategoryKey = rawCategoryKey;
}
- public void setConfiguration(ProductConfiguration configuration) {
- this.configuration = configuration;
- }
- // Convenience: best offer price for Gunbuilder
-public BigDecimal getBestOfferPrice() {
- if (offers == null || offers.isEmpty()) {
- return BigDecimal.ZERO;
+ public ImportStatus getImportStatus() { return importStatus; }
+ public void setImportStatus(ImportStatus importStatus) {
+ this.importStatus = importStatus;
}
- return offers.stream()
- // pick sale_price if present, otherwise retail_price
- .map(offer -> {
- if (offer.getSalePrice() != null) {
- return offer.getSalePrice();
- }
- return offer.getRetailPrice();
- })
- .filter(Objects::nonNull)
- .min(BigDecimal::compareTo)
- .orElse(BigDecimal.ZERO);
-}
+ public Set getOffers() { return offers; }
+ public void setOffers(Set offers) { this.offers = offers; }
+
+ // --- computed helpers ---
+
+ public BigDecimal getBestOfferPrice() {
+ if (offers == null || offers.isEmpty()) return BigDecimal.ZERO;
+
+ return offers.stream()
+ .map(offer -> offer.getSalePrice() != null
+ ? offer.getSalePrice()
+ : offer.getRetailPrice())
+ .filter(Objects::nonNull)
+ .min(BigDecimal::compareTo)
+ .orElse(BigDecimal.ZERO);
+ }
- // Convenience: URL for the best-priced offer
public String getBestOfferBuyUrl() {
- if (offers == null || offers.isEmpty()) {
- return null;
- }
+ if (offers == null || offers.isEmpty()) return null;
return offers.stream()
.sorted(Comparator.comparing(offer -> {
- if (offer.getSalePrice() != null) {
- return offer.getSalePrice();
- }
+ if (offer.getSalePrice() != null) return offer.getSalePrice();
return offer.getRetailPrice();
}, Comparator.nullsLast(BigDecimal::compareTo)))
.map(ProductOffer::getAffiliateUrl)
@@ -307,4 +212,4 @@ public BigDecimal getBestOfferPrice() {
.findFirst()
.orElse(null);
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java
index f26eca3..6a4de92 100644
--- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java
@@ -1,5 +1,6 @@
package group.goforward.ballistic.repos;
+import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import java.util.List;
import java.util.Optional;
@@ -13,5 +14,15 @@ public interface MerchantCategoryMappingRepository
String rawCategory
);
+ Optional findByMerchantIdAndRawCategory(
+ Integer merchantId,
+ String rawCategory
+ );
+
List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
+
+ Optional findByMerchantAndRawCategoryIgnoreCase(
+ Merchant merchant,
+ String rawCategory
+ );
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java
index 6178413..bf59f18 100644
--- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java
@@ -2,6 +2,7 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.ProductOffer;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
import java.util.Collection;
import java.util.List;
@@ -19,4 +20,15 @@ public interface ProductOfferRepository extends JpaRepository countByMerchantPlatformAndStatus();
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
index 1801d49..bd20f6b 100644
--- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
@@ -1,6 +1,7 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Brand;
+import group.goforward.ballistic.model.ImportStatus;
import group.goforward.ballistic.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -8,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
+import java.util.Map;
public interface ProductRepository extends JpaRepository {
@@ -65,7 +67,8 @@ public interface ProductRepository extends JpaRepository {
List findTop5ByPlatformWithBrand(@Param("platform") String platform);
// -------------------------------------------------
- // Used by GunbuilderProductService
+ // Used by GunbuilderProductService (builder UI)
+ // Only returns MAPPED products
// -------------------------------------------------
@Query("""
@@ -75,10 +78,98 @@ public interface ProductRepository extends JpaRepository {
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
AND p.partRole IN :partRoles
+ AND p.importStatus = :status
AND p.deletedAt IS NULL
""")
List findForGunbuilderByPlatformAndPartRoles(
@Param("platform") String platform,
- @Param("partRoles") Collection partRoles
+ @Param("partRoles") Collection partRoles,
+ @Param("status") ImportStatus status
+ );
+
+ // -------------------------------------------------
+ // Admin import-status dashboard (summary)
+ // -------------------------------------------------
+ @Query("""
+ SELECT p.importStatus AS status, COUNT(p) AS count
+ FROM Product p
+ WHERE p.deletedAt IS NULL
+ GROUP BY p.importStatus
+ """)
+ List