diff --git a/pom.xml b/pom.xml
index eff2159..6b038c0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
ballistic
0.0.1-SNAPSHOT
ballistic
- Ballistic Builder API
+ Battl Builder API
diff --git a/src/main/java/group/goforward/ballistic/BallisticApplication.java b/src/main/java/group/goforward/ballistic/BattlBuilderApplication.java
similarity index 82%
rename from src/main/java/group/goforward/ballistic/BallisticApplication.java
rename to src/main/java/group/goforward/ballistic/BattlBuilderApplication.java
index fbf9d94..e7ed88e 100644
--- a/src/main/java/group/goforward/ballistic/BallisticApplication.java
+++ b/src/main/java/group/goforward/ballistic/BattlBuilderApplication.java
@@ -10,9 +10,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@EnableCaching
@EntityScan(basePackages = "group.goforward.ballistic.model")
@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos")
-public class BallisticApplication {
+public class BattlBuilderApplication {
public static void main(String[] args) {
- SpringApplication.run(BallisticApplication.class, args);
+ SpringApplication.run(BattlBuilderApplication.class, args);
}
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/configuration/package-info.java b/src/main/java/group/goforward/ballistic/configuration/package-info.java
index 34f56da..304c520 100644
--- a/src/main/java/group/goforward/ballistic/configuration/package-info.java
+++ b/src/main/java/group/goforward/ballistic/configuration/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/controllers/admin/AdminDashboardController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminDashboardController.java
new file mode 100644
index 0000000..0b35b39
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminDashboardController.java
@@ -0,0 +1,25 @@
+package group.goforward.ballistic.web;
+
+import group.goforward.ballistic.services.AdminDashboardService;
+import group.goforward.ballistic.web.dto.AdminDashboardOverviewDto;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/admin/dashboard")
+public class AdminDashboardController {
+
+ private final AdminDashboardService adminDashboardService;
+
+ public AdminDashboardController(AdminDashboardService adminDashboardService) {
+ this.adminDashboardService = adminDashboardService;
+ }
+
+ @GetMapping("/overview")
+ public ResponseEntity getOverview() {
+ AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
+ return ResponseEntity.ok(dto);
+ }
+}
\ No newline at end of file
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/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/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/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/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/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/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 ad91c1e..8886663 100644
--- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
@@ -1,12 +1,18 @@
package group.goforward.ballistic.repos;
+import group.goforward.ballistic.model.ImportStatus;
+import group.goforward.ballistic.model.Merchant;
+import group.goforward.ballistic.model.MerchantCategoryMapping;
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.Query;
import org.springframework.data.repository.query.Param;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Collection;
import java.util.List;
+import java.util.Map;
public interface ProductRepository extends JpaRepository {
@@ -18,6 +24,8 @@ public interface ProductRepository extends JpaRepository {
List findAllByBrandAndUpc(Brand brand, String upc);
+ long countByImportStatus(ImportStatus importStatus);
+
boolean existsBySlug(String slug);
// -------------------------------------------------
@@ -25,16 +33,16 @@ public interface ProductRepository extends JpaRepository {
// -------------------------------------------------
@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
+ """)
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,16 +58,149 @@ List findByPlatformWithBrandNQ(@Param("platform") String platform);
);
// -------------------------------------------------
- // Used by Gunbuilder service (if you wired this)
+ // Used by /api/gunbuilder/test-products-db
// -------------------------------------------------
@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 findSomethingForGunbuilder(@Param("platform") String platform);
+ 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 (builder UI)
+ // Only returns MAPPED products
+ // -------------------------------------------------
+
+ @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.partRole IN :partRoles
+ AND p.importStatus = :status
+ AND p.deletedAt IS NULL
+ """)
+ List findForGunbuilderByPlatformAndPartRoles(
+ @Param("platform") String platform,
+ @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