diff --git a/pom.xml b/pom.xml
index dfab9ac..1db82ea 100644
--- a/pom.xml
+++ b/pom.xml
@@ -182,7 +182,7 @@
21
21
- --enable-preview
+
diff --git a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java
index 60a82ba..95fdbce 100644
--- a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java
+++ b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java
@@ -6,16 +6,7 @@ import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
-
-/**
- * merchant_category_mappings is intentionally limited to:
- * - merchant
- * - raw_category
- * - mapped_part_role
- *
- * It does NOT determine platform or confidence.
- * Platform is inferred at classification time by feed/rules.
- */
+import java.time.ZoneOffset;
@Entity
@Table(name = "merchant_category_map")
@@ -36,11 +27,29 @@ public class MerchantCategoryMap {
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
private String rawCategory;
- @Column(name = "part_role", length = 255)
- private String partRole;
+ /**
+ * Canonical role you want to classify to.
+ * Prefer this over partRole if present (legacy).
+ */
+ @Column(name = "canonical_part_role", length = 255)
+ private String canonicalPartRole;
-// @Column(name = "mapped_configuration", length = Integer.MAX_VALUE)
-// private String mappedConfiguration;
+ /**
+ * Legacy / transitional column. Keep for now so old rows still work.
+ */
+// @Column(name = "part_role", length = 255)
+// private String partRole;
+
+ /**
+ * Optional: if present, allows platform-aware mappings.
+ * Recommended values: "AR-15", "AR-10", "AR-9", "AK-47", or "ANY".
+ */
+ @Column(name = "platform", length = 64)
+ private String platform;
+
+ @NotNull
+ @Column(name = "enabled", nullable = false)
+ private Boolean enabled = true;
@NotNull
@Column(name = "created_at", nullable = false)
@@ -53,6 +62,20 @@ public class MerchantCategoryMap {
@Column(name = "deleted_at")
private OffsetDateTime deletedAt;
+ @PrePersist
+ public void prePersist() {
+ OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
+ if (createdAt == null) createdAt = now;
+ if (updatedAt == null) updatedAt = now;
+ if (enabled == null) enabled = true;
+ }
+
+ @PreUpdate
+ public void preUpdate() {
+ updatedAt = OffsetDateTime.now(ZoneOffset.UTC);
+ if (enabled == null) enabled = true;
+ }
+
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
@@ -62,11 +85,14 @@ public class MerchantCategoryMap {
public String getRawCategory() { return rawCategory; }
public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; }
- public String getPartRole() { return partRole; }
- public void setPartRole(String partRole) { this.partRole = partRole; }
+ public String getCanonicalPartRole() { return canonicalPartRole; }
+ public void setCanonicalPartRole(String canonicalPartRole) { this.canonicalPartRole = canonicalPartRole; }
-// public String getMappedConfiguration() { return mappedConfiguration; }
-// public void setMappedConfiguration(String mappedConfiguration) { this.mappedConfiguration = mappedConfiguration; }
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public Boolean getEnabled() { return enabled; }
+ public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
diff --git a/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java b/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java
new file mode 100644
index 0000000..0ac701f
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java
@@ -0,0 +1,9 @@
+package group.goforward.battlbuilder.model;
+
+public enum PartRoleSource {
+ MERCHANT_MAP,
+ RULES,
+ INFERRED,
+ OVERRIDE,
+ UNKNOWN
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/model/Product.java b/src/main/java/group/goforward/battlbuilder/model/Product.java
index 4244bb4..0408384 100644
--- a/src/main/java/group/goforward/battlbuilder/model/Product.java
+++ b/src/main/java/group/goforward/battlbuilder/model/Product.java
@@ -8,6 +8,7 @@ import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
+import group.goforward.battlbuilder.model.PartRoleSource;
@Entity
@Table(name = "products")
@@ -61,6 +62,24 @@ public class Product {
@Column(name = "part_role")
private String partRole;
+ // --- classification provenance ---
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "part_role_source", nullable = false)
+ private PartRoleSource partRoleSource = PartRoleSource.UNKNOWN;
+
+ @Column(name = "classifier_version", length = 32)
+ private String classifierVersion;
+
+ @Column(name = "classification_reason", length = 512)
+ private String classificationReason;
+
+ @Column(name = "classified_at")
+ private Instant classifiedAt;
+
+ @Column(name = "part_role_locked", nullable = false)
+ private Boolean partRoleLocked = false;
+
@Column(name = "configuration")
@Enumerated(EnumType.STRING)
private ProductConfiguration configuration;
@@ -203,6 +222,21 @@ public class Product {
public Set getOffers() { return offers; }
public void setOffers(Set offers) { this.offers = offers; }
+ public PartRoleSource getPartRoleSource() { return partRoleSource; }
+ public void setPartRoleSource(PartRoleSource partRoleSource) { this.partRoleSource = partRoleSource; }
+
+ public String getClassifierVersion() { return classifierVersion; }
+ public void setClassifierVersion(String classifierVersion) { this.classifierVersion = classifierVersion; }
+
+ public String getClassificationReason() { return classificationReason; }
+ public void setClassificationReason(String classificationReason) { this.classificationReason = classificationReason; }
+
+ public Instant getClassifiedAt() { return classifiedAt; }
+ public void setClassifiedAt(Instant classifiedAt) { this.classifiedAt = classifiedAt; }
+
+ public Boolean getPartRoleLocked() { return partRoleLocked; }
+ public void setPartRoleLocked(Boolean partRoleLocked) { this.partRoleLocked = partRoleLocked; }
+
// --- computed helpers ---
public BigDecimal getBestOfferPrice() {
diff --git a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java
index d856819..63dd5c4 100644
--- a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java
+++ b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java
@@ -1,23 +1,37 @@
package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
-import java.util.Optional;
-
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
+import java.util.Optional;
@Repository
public interface MerchantCategoryMapRepository extends JpaRepository {
- List findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
+ @Query("""
+ select mcm.canonicalPartRole
+ from MerchantCategoryMap mcm
+ where mcm.merchant.id = :merchantId
+ and mcm.rawCategory = :rawCategory
+ and mcm.enabled = true
+ and mcm.deletedAt is null
+ order by mcm.updatedAt desc
+ """)
+ List findCanonicalPartRoles(
+ @Param("merchantId") Integer merchantId,
+ @Param("rawCategory") String rawCategory
+ );
+
+
+ // Optional convenience method (you can keep, but service logic will handle platform preference)
+ Optional findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId,
String rawCategory
);
- Optional findFirstByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
- Integer merchantId,
- String rawCategory
- );
+
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
index c0fa3e6..f2139ee 100644
--- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
+++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
@@ -109,6 +109,17 @@ public interface ProductRepository extends JpaRepository {
@Param("status") ImportStatus status
);
+ @Query(value = """
+ select distinct p.*
+ from products p
+ join product_offers po on po.product_id = p.id
+ where po.merchant_id = :merchantId
+ and p.raw_category_key = :rawCategoryKey
+ and p.deleted_at is null
+ """, nativeQuery = true)
+ List findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId,
+ @Param("rawCategoryKey") String rawCategoryKey);
+
// -------------------------------------------------
// Admin import-status dashboard (summary)
// -------------------------------------------------
@@ -174,64 +185,65 @@ public interface ProductRepository extends JpaRepository {
);
// -------------------------------------------------
- // Mapping admin – pending buckets (all merchants)
- // -------------------------------------------------
+// Mapping admin – pending buckets (all merchants)
+// Pending = no MerchantCategoryMap row exists
+// -------------------------------------------------
@Query("""
- SELECT m.id AS merchantId,
- m.name AS merchantName,
- p.rawCategoryKey AS rawCategoryKey,
- mcm.partRole AS mappedPartRole,
- COUNT(DISTINCT p.id) AS productCount
- FROM Product p
- JOIN p.offers o
- JOIN o.merchant m
- LEFT JOIN MerchantCategoryMap mcm
- ON mcm.merchant.id = m.id
- AND mcm.rawCategory = p.rawCategoryKey
- AND mcm.deletedAt IS NULL
- WHERE p.importStatus = :status
- GROUP BY m.id, m.name, p.rawCategoryKey, mcm.partRole
- ORDER BY productCount DESC
- """)
+SELECT m.id AS merchantId,
+ m.name AS merchantName,
+ p.rawCategoryKey AS rawCategoryKey,
+ COUNT(DISTINCT p.id) AS productCount
+FROM Product p
+JOIN p.offers o
+JOIN o.merchant m
+LEFT JOIN MerchantCategoryMap mcm
+ ON mcm.merchant.id = m.id
+ AND mcm.rawCategory = p.rawCategoryKey
+ AND mcm.deletedAt IS NULL
+WHERE p.importStatus = :status
+ AND p.rawCategoryKey IS NOT NULL
+GROUP BY m.id, m.name, p.rawCategoryKey
+HAVING COUNT(mcm.id) = 0
+ORDER BY productCount DESC
+""")
List