mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
my brain is melting. reworked the categorization
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -182,7 +182,7 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<source>21</source>
|
<source>21</source>
|
||||||
<target>21</target>
|
<target>21</target>
|
||||||
<compilerArgs>--enable-preview</compilerArgs>
|
<!-- <compilerArgs>--enable-preview</compilerArgs>-->
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,7 @@ import org.hibernate.annotations.OnDelete;
|
|||||||
import org.hibernate.annotations.OnDeleteAction;
|
import org.hibernate.annotations.OnDeleteAction;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "merchant_category_map")
|
@Table(name = "merchant_category_map")
|
||||||
@@ -36,11 +27,29 @@ public class MerchantCategoryMap {
|
|||||||
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String rawCategory;
|
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
|
@NotNull
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
@@ -53,6 +62,20 @@ public class MerchantCategoryMap {
|
|||||||
@Column(name = "deleted_at")
|
@Column(name = "deleted_at")
|
||||||
private OffsetDateTime deletedAt;
|
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 Integer getId() { return id; }
|
||||||
public void setId(Integer id) { this.id = id; }
|
public void setId(Integer id) { this.id = id; }
|
||||||
|
|
||||||
@@ -62,11 +85,14 @@ public class MerchantCategoryMap {
|
|||||||
public String getRawCategory() { return rawCategory; }
|
public String getRawCategory() { return rawCategory; }
|
||||||
public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; }
|
public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; }
|
||||||
|
|
||||||
public String getPartRole() { return partRole; }
|
public String getCanonicalPartRole() { return canonicalPartRole; }
|
||||||
public void setPartRole(String partRole) { this.partRole = partRole; }
|
public void setCanonicalPartRole(String canonicalPartRole) { this.canonicalPartRole = canonicalPartRole; }
|
||||||
|
|
||||||
// public String getMappedConfiguration() { return mappedConfiguration; }
|
public String getPlatform() { return platform; }
|
||||||
// public void setMappedConfiguration(String mappedConfiguration) { this.mappedConfiguration = mappedConfiguration; }
|
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 OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package group.goforward.battlbuilder.model;
|
||||||
|
|
||||||
|
public enum PartRoleSource {
|
||||||
|
MERCHANT_MAP,
|
||||||
|
RULES,
|
||||||
|
INFERRED,
|
||||||
|
OVERRIDE,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import java.util.Comparator;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "products")
|
@Table(name = "products")
|
||||||
@@ -61,6 +62,24 @@ public class Product {
|
|||||||
@Column(name = "part_role")
|
@Column(name = "part_role")
|
||||||
private String partRole;
|
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")
|
@Column(name = "configuration")
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private ProductConfiguration configuration;
|
private ProductConfiguration configuration;
|
||||||
@@ -203,6 +222,21 @@ public class Product {
|
|||||||
public Set<ProductOffer> getOffers() { return offers; }
|
public Set<ProductOffer> getOffers() { return offers; }
|
||||||
public void setOffers(Set<ProductOffer> offers) { this.offers = offers; }
|
public void setOffers(Set<ProductOffer> 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 ---
|
// --- computed helpers ---
|
||||||
|
|
||||||
public BigDecimal getBestOfferPrice() {
|
public BigDecimal getBestOfferPrice() {
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
package group.goforward.battlbuilder.repos;
|
package group.goforward.battlbuilder.repos;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
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.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
||||||
|
|
||||||
List<MerchantCategoryMap> 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<String> findCanonicalPartRoles(
|
||||||
|
@Param("merchantId") Integer merchantId,
|
||||||
|
@Param("rawCategory") String rawCategory
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Optional convenience method (you can keep, but service logic will handle platform preference)
|
||||||
|
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String rawCategory
|
String rawCategory
|
||||||
);
|
);
|
||||||
|
|
||||||
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
|
||||||
Integer merchantId,
|
|
||||||
String rawCategory
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,17 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
@Param("status") ImportStatus status
|
@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<Product> findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId,
|
||||||
|
@Param("rawCategoryKey") String rawCategoryKey);
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Admin import-status dashboard (summary)
|
// Admin import-status dashboard (summary)
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
@@ -174,64 +185,65 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Mapping admin – pending buckets (all merchants)
|
// Mapping admin – pending buckets (all merchants)
|
||||||
// -------------------------------------------------
|
// Pending = no MerchantCategoryMap row exists
|
||||||
|
// -------------------------------------------------
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT m.id AS merchantId,
|
SELECT m.id AS merchantId,
|
||||||
m.name AS merchantName,
|
m.name AS merchantName,
|
||||||
p.rawCategoryKey AS rawCategoryKey,
|
p.rawCategoryKey AS rawCategoryKey,
|
||||||
mcm.partRole AS mappedPartRole,
|
|
||||||
COUNT(DISTINCT p.id) AS productCount
|
COUNT(DISTINCT p.id) AS productCount
|
||||||
FROM Product p
|
FROM Product p
|
||||||
JOIN p.offers o
|
JOIN p.offers o
|
||||||
JOIN o.merchant m
|
JOIN o.merchant m
|
||||||
LEFT JOIN MerchantCategoryMap mcm
|
LEFT JOIN MerchantCategoryMap mcm
|
||||||
ON mcm.merchant.id = m.id
|
ON mcm.merchant.id = m.id
|
||||||
AND mcm.rawCategory = p.rawCategoryKey
|
AND mcm.rawCategory = p.rawCategoryKey
|
||||||
AND mcm.deletedAt IS NULL
|
AND mcm.deletedAt IS NULL
|
||||||
WHERE p.importStatus = :status
|
WHERE p.importStatus = :status
|
||||||
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.partRole
|
AND p.rawCategoryKey IS NOT NULL
|
||||||
ORDER BY productCount DESC
|
GROUP BY m.id, m.name, p.rawCategoryKey
|
||||||
""")
|
HAVING COUNT(mcm.id) = 0
|
||||||
|
ORDER BY productCount DESC
|
||||||
|
""")
|
||||||
List<Object[]> findPendingMappingBuckets(@Param("status") ImportStatus status);
|
List<Object[]> findPendingMappingBuckets(@Param("status") ImportStatus status);
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------
|
|
||||||
// Mapping admin – pending buckets for a single merchant
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
|
// Mapping admin – pending buckets for a single merchant
|
||||||
|
// Pending = no MerchantCategoryMap row exists
|
||||||
|
// -------------------------------------------------
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT m.id AS merchantId,
|
SELECT m.id AS merchantId,
|
||||||
m.name AS merchantName,
|
m.name AS merchantName,
|
||||||
p.rawCategoryKey AS rawCategoryKey,
|
p.rawCategoryKey AS rawCategoryKey,
|
||||||
mcm.partRole AS mappedPartRole,
|
|
||||||
COUNT(DISTINCT p.id) AS productCount
|
COUNT(DISTINCT p.id) AS productCount
|
||||||
FROM Product p
|
FROM Product p
|
||||||
JOIN p.offers o
|
JOIN p.offers o
|
||||||
JOIN o.merchant m
|
JOIN o.merchant m
|
||||||
LEFT JOIN MerchantCategoryMap mcm
|
LEFT JOIN MerchantCategoryMap mcm
|
||||||
ON mcm.merchant.id = m.id
|
ON mcm.merchant.id = m.id
|
||||||
AND mcm.rawCategory = p.rawCategoryKey
|
AND mcm.rawCategory = p.rawCategoryKey
|
||||||
AND mcm.deletedAt IS NULL
|
AND mcm.deletedAt IS NULL
|
||||||
WHERE p.importStatus = :status
|
WHERE p.importStatus = :status
|
||||||
AND m.id = :merchantId
|
AND m.id = :merchantId
|
||||||
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.partRole
|
AND p.rawCategoryKey IS NOT NULL
|
||||||
ORDER BY productCount DESC
|
GROUP BY m.id, m.name, p.rawCategoryKey
|
||||||
""")
|
HAVING COUNT(mcm.id) = 0
|
||||||
|
ORDER BY productCount DESC
|
||||||
|
""")
|
||||||
List<Object[]> findPendingMappingBucketsForMerchant(
|
List<Object[]> findPendingMappingBucketsForMerchant(
|
||||||
@Param("merchantId") Integer merchantId,
|
@Param("merchantId") Integer merchantId,
|
||||||
@Param("status") ImportStatus status
|
@Param("status") ImportStatus status
|
||||||
);
|
);
|
||||||
|
|
||||||
@Query(value = """
|
@Query("""
|
||||||
select distinct p.*
|
SELECT DISTINCT p
|
||||||
from products p
|
FROM Product p
|
||||||
join product_offers po on po.product_id = p.id
|
JOIN p.offers o
|
||||||
where po.merchant_id = :merchantId
|
WHERE o.merchant.id = :merchantId
|
||||||
and p.import_status = 'PENDING_MAPPING'
|
AND p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING
|
||||||
and p.deleted_at is null
|
AND p.deletedAt IS NULL
|
||||||
""", nativeQuery = true)
|
""")
|
||||||
|
|
||||||
|
|
||||||
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
||||||
|
|
||||||
Page<Product> findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable);
|
Page<Product> findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable);
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ package group.goforward.battlbuilder.services;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
|
|
||||||
public interface CategoryClassificationService {
|
public interface CategoryClassificationService {
|
||||||
|
|
||||||
record Result(
|
record Result(
|
||||||
String platform, // e.g. "AR-15"
|
String platform,
|
||||||
String partRole, // e.g. "muzzle-device"
|
String partRole,
|
||||||
String rawCategoryKey // e.g. "Rifle Parts > Muzzle Devices > Flash Hiders"
|
String rawCategoryKey,
|
||||||
|
PartRoleSource source,
|
||||||
|
String reason
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy convenience: derives rawCategoryKey + platform from row.
|
||||||
|
*/
|
||||||
Result classify(Merchant merchant, MerchantFeedRow row);
|
Result classify(Merchant merchant, MerchantFeedRow row);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferred for ETL: caller already computed platform + rawCategoryKey.
|
||||||
|
* This prevents platformResolver overrides from drifting vs mapping selection.
|
||||||
|
*/
|
||||||
|
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
|
||||||
}
|
}
|
||||||
@@ -18,67 +18,89 @@ public class MappingAdminService {
|
|||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final ReclassificationService reclassificationService;
|
||||||
|
|
||||||
public MappingAdminService(
|
public MappingAdminService(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||||
MerchantRepository merchantRepository
|
MerchantRepository merchantRepository,
|
||||||
|
ReclassificationService reclassificationService
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.reclassificationService = reclassificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
List<Object[]> rows = productRepository.findPendingMappingBuckets(
|
List<Object[]> rows =
|
||||||
ImportStatus.PENDING_MAPPING
|
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
||||||
);
|
|
||||||
|
|
||||||
return rows.stream()
|
return rows.stream()
|
||||||
.map(row -> {
|
.map(row -> {
|
||||||
Integer merchantId = (Integer) row[0];
|
Integer merchantId = (Integer) row[0];
|
||||||
String merchantName = (String) row[1];
|
String merchantName = (String) row[1];
|
||||||
String rawCategoryKey = (String) row[2];
|
String rawCategoryKey = (String) row[2];
|
||||||
String mappedPartRole = (String) row[3];
|
Long count = (Long) row[3];
|
||||||
Long count = (Long) row[4];
|
|
||||||
|
|
||||||
return new PendingMappingBucketDto(
|
return new PendingMappingBucketDto(
|
||||||
merchantId,
|
merchantId,
|
||||||
merchantName,
|
merchantName,
|
||||||
rawCategoryKey,
|
rawCategoryKey,
|
||||||
(mappedPartRole != null && !mappedPartRole.isBlank()) ? mappedPartRole : null,
|
|
||||||
count != null ? count : 0L
|
count != null ? count : 0L
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates/updates the mapping row, then immediately applies it to products so the UI updates
|
||||||
|
* without requiring a re-import.
|
||||||
|
*
|
||||||
|
* @return number of products updated
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||||
if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) {
|
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|
||||||
|
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||||
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||||
|
|
||||||
List<MerchantCategoryMap> existing =
|
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
|
||||||
merchantId,
|
|
||||||
rawCategoryKey
|
|
||||||
);
|
|
||||||
|
|
||||||
MerchantCategoryMap mapping = existing.isEmpty()
|
|
||||||
? new MerchantCategoryMap()
|
|
||||||
: existing.get(0);
|
|
||||||
|
|
||||||
if (mapping.getId() == null) {
|
|
||||||
mapping.setMerchant(merchant);
|
mapping.setMerchant(merchant);
|
||||||
mapping.setRawCategory(rawCategoryKey);
|
mapping.setRawCategory(rawCategoryKey);
|
||||||
|
mapping.setEnabled(true);
|
||||||
|
|
||||||
|
// SOURCE OF TRUTH
|
||||||
|
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||||
|
|
||||||
|
merchantCategoryMapRepository.save(mapping);
|
||||||
|
|
||||||
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapping.setPartRole(mappedPartRole.trim());
|
/**
|
||||||
merchantCategoryMapRepository.save(mapping);
|
* Manual “apply mapping to products” (no mapping row changes).
|
||||||
|
*
|
||||||
|
* @return number of products updated
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||||
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
|
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||||
|
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||||
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateInputs(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||||
|
if (merchantId == null
|
||||||
|
|| rawCategoryKey == null || rawCategoryKey.isBlank()
|
||||||
|
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,5 @@ package group.goforward.battlbuilder.services;
|
|||||||
|
|
||||||
public interface ReclassificationService {
|
public interface ReclassificationService {
|
||||||
int reclassifyPendingForMerchant(Integer merchantId);
|
int reclassifyPendingForMerchant(Integer merchantId);
|
||||||
|
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
||||||
}
|
}
|
||||||
@@ -3,71 +3,78 @@ package group.goforward.battlbuilder.services.impl;
|
|||||||
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
|
|
||||||
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
||||||
|
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
private final PartRoleResolver partRoleResolver;
|
private final PartRoleResolver partRoleResolver;
|
||||||
|
|
||||||
public CategoryClassificationServiceImpl(
|
public CategoryClassificationServiceImpl(
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||||
PartRoleResolver partRoleResolver
|
PartRoleResolver partRoleResolver
|
||||||
) {
|
) {
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||||
this.partRoleResolver = partRoleResolver;
|
this.partRoleResolver = partRoleResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
|
String platformFinal = inferPlatform(row);
|
||||||
// Platform is inferred from feed/rules; mapping table does not store platform.
|
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||||
String platform = inferPlatform(row);
|
return classify(merchant, row, platformFinal, rawCategoryKey);
|
||||||
if (platform == null) platform = "AR-15";
|
|
||||||
|
|
||||||
final String platformFinal = platform;
|
|
||||||
final String rawCategoryKeyFinal = rawCategoryKey;
|
|
||||||
|
|
||||||
// Part role from mapping (if present), else rules, else infer
|
|
||||||
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal)
|
|
||||||
.orElseGet(() -> {
|
|
||||||
String resolved = partRoleResolver.resolve(
|
|
||||||
platformFinal,
|
|
||||||
row.productName(),
|
|
||||||
rawCategoryKeyFinal
|
|
||||||
);
|
|
||||||
if (resolved != null && !resolved.isBlank()) return resolved;
|
|
||||||
|
|
||||||
// ✅ IMPORTANT: pass rawCategoryKey so inference can see "Complete Uppers" etc.
|
|
||||||
return inferPartRole(row, rawCategoryKeyFinal);
|
|
||||||
});
|
|
||||||
|
|
||||||
partRole = normalizePartRole(partRole);
|
|
||||||
|
|
||||||
return new Result(platformFinal, partRole, rawCategoryKeyFinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<String> resolvePartRoleFromMapping(Merchant merchant, String rawCategoryKey) {
|
@Override
|
||||||
if (merchant == null || rawCategoryKey == null) return Optional.empty();
|
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
||||||
|
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||||
|
|
||||||
List<MerchantCategoryMap> mappings =
|
// 1) merchant map (authoritative if present)
|
||||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
merchant.getId(), rawCategoryKey
|
merchant != null ? merchant.getId() : null,
|
||||||
|
rawCategoryKey,
|
||||||
|
platformFinal
|
||||||
);
|
);
|
||||||
|
|
||||||
return mappings.stream()
|
if (mapped.isPresent()) {
|
||||||
.map(MerchantCategoryMap::getPartRole)
|
String role = normalizePartRole(mapped.get());
|
||||||
.filter(r -> r != null && !r.isBlank())
|
return new Result(
|
||||||
.findFirst();
|
platformFinal,
|
||||||
|
role,
|
||||||
|
rawCategoryKey,
|
||||||
|
PartRoleSource.MERCHANT_MAP,
|
||||||
|
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) rules
|
||||||
|
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
|
||||||
|
if (resolved != null && !resolved.isBlank()) {
|
||||||
|
String role = normalizePartRole(resolved);
|
||||||
|
return new Result(
|
||||||
|
platformFinal,
|
||||||
|
role,
|
||||||
|
rawCategoryKey,
|
||||||
|
PartRoleSource.RULES,
|
||||||
|
"PartRoleResolver matched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
|
||||||
|
return new Result(
|
||||||
|
platformFinal,
|
||||||
|
"unknown",
|
||||||
|
rawCategoryKey,
|
||||||
|
PartRoleSource.UNKNOWN,
|
||||||
|
"no mapping or rules match"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||||
@@ -98,112 +105,17 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
).toLowerCase(Locale.ROOT);
|
).toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
||||||
if (blob.contains("ar-10") || blob.contains("ar10")) return "AR-10";
|
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
|
||||||
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||||
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||||
|
|
||||||
return "AR-15"; // safe default
|
return "AR-15";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback inference ONLY. Prefer:
|
|
||||||
* 1) merchant mapping table
|
|
||||||
* 2) PartRoleResolver rules
|
|
||||||
* 3) this method
|
|
||||||
*
|
|
||||||
* Key principle: use rawCategoryKey + productName (not just subCategory),
|
|
||||||
* because merchants often encode the important signal in category paths.
|
|
||||||
*/
|
|
||||||
private String inferPartRole(MerchantFeedRow row, String rawCategoryKey) {
|
|
||||||
String subCat = trimOrNull(row.subCategory());
|
|
||||||
String cat = trimOrNull(row.category());
|
|
||||||
String dept = trimOrNull(row.department());
|
|
||||||
String name = trimOrNull(row.productName());
|
|
||||||
|
|
||||||
// Combine ALL possible signals
|
|
||||||
String combined = coalesce(
|
|
||||||
rawCategoryKey,
|
|
||||||
joinNonNull(" > ", dept, cat, subCat),
|
|
||||||
cat,
|
|
||||||
subCat
|
|
||||||
);
|
|
||||||
|
|
||||||
String combinedLower = combined == null ? "" : combined.toLowerCase(Locale.ROOT);
|
|
||||||
String nameLower = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
|
||||||
|
|
||||||
// ---------- HIGH PRIORITY: COMPLETE ASSEMBLIES ----------
|
|
||||||
// rawCategoryKey from your DB shows "Ar-15 Complete Uppers" — grab that first.
|
|
||||||
boolean looksLikeCompleteUpper =
|
|
||||||
combinedLower.contains("complete upper") ||
|
|
||||||
combinedLower.contains("complete uppers") ||
|
|
||||||
combinedLower.contains("upper receiver assembly") ||
|
|
||||||
combinedLower.contains("barreled upper") ||
|
|
||||||
nameLower.contains("complete upper") ||
|
|
||||||
nameLower.contains("complete upper receiver") ||
|
|
||||||
nameLower.contains("barreled upper") ||
|
|
||||||
nameLower.contains("upper receiver assembly");
|
|
||||||
|
|
||||||
if (looksLikeCompleteUpper) return "complete-upper";
|
|
||||||
|
|
||||||
boolean looksLikeCompleteLower =
|
|
||||||
combinedLower.contains("complete lower") ||
|
|
||||||
combinedLower.contains("complete lowers") ||
|
|
||||||
nameLower.contains("complete lower");
|
|
||||||
|
|
||||||
if (looksLikeCompleteLower) return "complete-lower";
|
|
||||||
|
|
||||||
// ---------- RECEIVERS ----------
|
|
||||||
// If we see "stripped upper", prefer upper-receiver. Otherwise "upper" can be generic.
|
|
||||||
boolean looksLikeStrippedUpper =
|
|
||||||
combinedLower.contains("stripped upper") ||
|
|
||||||
nameLower.contains("stripped upper");
|
|
||||||
|
|
||||||
if (looksLikeStrippedUpper) return "upper-receiver";
|
|
||||||
|
|
||||||
boolean looksLikeStrippedLower =
|
|
||||||
combinedLower.contains("stripped lower") ||
|
|
||||||
nameLower.contains("stripped lower");
|
|
||||||
|
|
||||||
if (looksLikeStrippedLower) return "lower-receiver";
|
|
||||||
|
|
||||||
// ---------- COMMON PARTS ----------
|
|
||||||
if (combinedLower.contains("handguard") || combinedLower.contains("rail")) return "handguard";
|
|
||||||
if (combinedLower.contains("barrel")) return "barrel";
|
|
||||||
if (combinedLower.contains("gas block") || combinedLower.contains("gas-block") || combinedLower.contains("gasblock")) return "gas-block";
|
|
||||||
if (combinedLower.contains("gas tube") || combinedLower.contains("gas-tube") || combinedLower.contains("gastube")) return "gas-tube";
|
|
||||||
if (combinedLower.contains("muzzle") || combinedLower.contains("brake") || combinedLower.contains("compensator")) return "muzzle-device";
|
|
||||||
if (combinedLower.contains("bolt carrier") || combinedLower.contains("bolt-carrier") || combinedLower.contains("bcg")) return "bcg";
|
|
||||||
if (combinedLower.contains("charging handle") || combinedLower.contains("charging-handle")) return "charging-handle";
|
|
||||||
|
|
||||||
if (combinedLower.contains("lower parts") || combinedLower.contains("lower-parts") || combinedLower.contains("lpk")) return "lower-parts";
|
|
||||||
if (combinedLower.contains("trigger")) return "trigger";
|
|
||||||
if (combinedLower.contains("pistol grip") || combinedLower.contains("grip")) return "grip";
|
|
||||||
if (combinedLower.contains("safety") || combinedLower.contains("selector")) return "safety";
|
|
||||||
if (combinedLower.contains("buffer")) return "buffer";
|
|
||||||
if (combinedLower.contains("stock") || combinedLower.contains("buttstock") || combinedLower.contains("brace")) return "stock";
|
|
||||||
|
|
||||||
if (combinedLower.contains("magazine") || combinedLower.contains(" mag ") || combinedLower.equals("mag")) return "magazine";
|
|
||||||
if (combinedLower.contains("sight")) return "sights";
|
|
||||||
if (combinedLower.contains("optic") || combinedLower.contains("scope")) return "optic";
|
|
||||||
if (combinedLower.contains("suppress")) return "suppressor";
|
|
||||||
if (combinedLower.contains("light") || combinedLower.contains("laser")) return "weapon-light";
|
|
||||||
if (combinedLower.contains("bipod")) return "bipod";
|
|
||||||
if (combinedLower.contains("sling")) return "sling";
|
|
||||||
if (combinedLower.contains("foregrip") || combinedLower.contains("vertical grip") || combinedLower.contains("angled")) return "foregrip";
|
|
||||||
if (combinedLower.contains("tool") || combinedLower.contains("wrench") || combinedLower.contains("armorer")) return "tools";
|
|
||||||
|
|
||||||
// ---------- LAST RESORT ----------
|
|
||||||
// If it says "upper" but NOT complete upper, keep it generic
|
|
||||||
if (combinedLower.contains("upper")) return "upper";
|
|
||||||
if (combinedLower.contains("lower")) return "lower";
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizePartRole(String partRole) {
|
private String normalizePartRole(String partRole) {
|
||||||
if (partRole == null) return "unknown";
|
if (partRole == null) return "unknown";
|
||||||
String t = partRole.trim().toLowerCase(Locale.ROOT)
|
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||||
.replace('_', '-');
|
|
||||||
return t.isBlank() ? "unknown" : t;
|
return t.isBlank() ? "unknown" : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,15 +132,4 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String joinNonNull(String sep, String... parts) {
|
|
||||||
if (parts == null || parts.length == 0) return null;
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (String p : parts) {
|
|
||||||
if (p == null || p.isBlank()) continue;
|
|
||||||
if (!sb.isEmpty()) sb.append(sep);
|
|
||||||
sb.append(p.trim());
|
|
||||||
}
|
|
||||||
return sb.isEmpty() ? null : sb.toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MerchantCategoryMappingService {
|
||||||
|
|
||||||
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
|
|
||||||
|
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
|
||||||
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<String> resolveMappedPartRole(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategoryKey,
|
||||||
|
String platformFinal
|
||||||
|
) {
|
||||||
|
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> canonicalRoles =
|
||||||
|
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
|
||||||
|
|
||||||
|
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalRoles.stream()
|
||||||
|
.filter(v -> v != null && !v.isBlank())
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted
|
/**
|
||||||
// This is the primary ETL pipeline that ingests merchant product feeds,
|
* @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on:
|
||||||
// normalizes them, classifies platform/part-role, and upserts products + offers.
|
* - merchant_category_map.canonical_part_role (authoritative)
|
||||||
//
|
* - PartRoleResolver rules
|
||||||
// IMPORTANT DESIGN NOTES:
|
* - ImportStatus.PENDING_MAPPING for anything unresolved
|
||||||
// - This importer is authoritative for what ends up in the DB
|
*/
|
||||||
// - PlatformResolver is applied HERE so unsupported products never leak downstream
|
|
||||||
// - UI, APIs, and builder assume DB data is already “clean enough”
|
|
||||||
package group.goforward.battlbuilder.services.impl;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
@@ -20,6 +18,8 @@ import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
|||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
|
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
|
||||||
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||||
|
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||||
|
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVParser;
|
import org.apache.commons.csv.CSVParser;
|
||||||
import org.apache.commons.csv.CSVRecord;
|
import org.apache.commons.csv.CSVRecord;
|
||||||
@@ -36,6 +36,7 @@ import java.net.URL;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MerchantFeedImportServiceImpl
|
* MerchantFeedImportServiceImpl
|
||||||
@@ -51,8 +52,8 @@ import java.util.*;
|
|||||||
* - Perfect classification (that’s iterative)
|
* - Perfect classification (that’s iterative)
|
||||||
* - UI-level filtering (handled later)
|
* - UI-level filtering (handled later)
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(forRemoval = false, since = "2025-12-28")
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
|
||||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||||
@@ -60,24 +61,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final BrandRepository brandRepository;
|
private final BrandRepository brandRepository;
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
// --- Classification ---
|
// --- Classification ---
|
||||||
// DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED)
|
// DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED)
|
||||||
|
private final CategoryClassificationService categoryClassificationService;
|
||||||
private final PlatformResolver platformResolver;
|
private final PlatformResolver platformResolver;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(
|
|
||||||
MerchantRepository merchantRepository,
|
|
||||||
BrandRepository brandRepository,
|
|
||||||
ProductRepository productRepository,
|
|
||||||
PlatformResolver platformResolver,
|
|
||||||
ProductOfferRepository productOfferRepository
|
|
||||||
) {
|
|
||||||
this.merchantRepository = merchantRepository;
|
|
||||||
this.brandRepository = brandRepository;
|
|
||||||
this.productRepository = productRepository;
|
|
||||||
this.platformResolver = platformResolver;
|
|
||||||
this.productOfferRepository = productOfferRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// FULL PRODUCT + OFFER IMPORT
|
// FULL PRODUCT + OFFER IMPORT
|
||||||
@@ -269,31 +259,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
|
|
||||||
p.setPlatform(finalPlatform);
|
p.setPlatform(finalPlatform);
|
||||||
}
|
}
|
||||||
|
// ---------- PART ROLE (AUTHORITATIVE) ----------
|
||||||
|
// Single source of truth: merchant map -> rules -> inference
|
||||||
|
CategoryClassificationService.Result classification =
|
||||||
|
categoryClassificationService.classify(merchant, row, p.getPlatform(), rawCategoryKey);
|
||||||
|
|
||||||
// ---------- PART ROLE (TEMPORARY) ----------
|
// Apply results
|
||||||
// This is intentionally weak — PartRoleResolver + mappings improve this later
|
p.setPartRole(classification.partRole());
|
||||||
String partRole = inferPartRole(row);
|
p.setPartRoleSource(classification.source());
|
||||||
if (partRole == null || partRole.isBlank()) {
|
p.setClassifierVersion("v2025-12-28.1");
|
||||||
partRole = "UNKNOWN";
|
p.setClassifiedAt(Instant.now());
|
||||||
} else {
|
p.setClassificationReason(classification.reason());
|
||||||
partRole = partRole.trim();
|
|
||||||
}
|
|
||||||
p.setPartRole(partRole);
|
|
||||||
|
|
||||||
// ---------- IMPORT STATUS ----------
|
// ---------- IMPORT STATUS ----------
|
||||||
// If platform is NOT-SUPPORTED, force PENDING so it's easy to audit.
|
|
||||||
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
|
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
|
||||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("UNKNOWN".equalsIgnoreCase(partRole)) {
|
if ("unknown".equalsIgnoreCase(classification.partRole())) {
|
||||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||||
} else {
|
} else {
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Offer upsert (full ETL)
|
// Offer upsert (full ETL)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -677,53 +666,19 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
return "AR-15"; // safe default
|
return "AR-15"; // safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
private String inferPartRole(MerchantFeedRow row) {
|
public MerchantFeedImportServiceImpl(
|
||||||
// Use more than just subCategory; complete uppers were being mislabeled previously.
|
MerchantRepository merchantRepository,
|
||||||
String dept = trimOrNull(row.department());
|
BrandRepository brandRepository,
|
||||||
String cat = trimOrNull(row.category());
|
ProductRepository productRepository,
|
||||||
String sub = trimOrNull(row.subCategory());
|
PlatformResolver platformResolver,
|
||||||
String name = trimOrNull(row.productName());
|
ProductOfferRepository productOfferRepository,
|
||||||
|
CategoryClassificationService categoryClassificationService
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
String combined = String.join(" ",
|
this.brandRepository = brandRepository;
|
||||||
coalesce(rawCategoryKey, ""),
|
this.productRepository = productRepository;
|
||||||
coalesce(dept, ""),
|
this.platformResolver = platformResolver;
|
||||||
coalesce(cat, ""),
|
this.productOfferRepository = productOfferRepository;
|
||||||
coalesce(sub, ""),
|
this.categoryClassificationService = categoryClassificationService;
|
||||||
coalesce(name, "")
|
|
||||||
).toLowerCase(Locale.ROOT);
|
|
||||||
|
|
||||||
// ---- High priority: complete assemblies ----
|
|
||||||
if (combined.contains("complete upper") ||
|
|
||||||
combined.contains("complete uppers") ||
|
|
||||||
combined.contains("upper receiver assembly") ||
|
|
||||||
combined.contains("barreled upper")) {
|
|
||||||
return "complete-upper";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (combined.contains("complete lower") ||
|
|
||||||
combined.contains("complete lowers") ||
|
|
||||||
combined.contains("lower receiver assembly")) {
|
|
||||||
return "complete-lower";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Receivers ----
|
|
||||||
if (combined.contains("stripped upper")) return "upper-receiver";
|
|
||||||
if (combined.contains("stripped lower")) return "lower-receiver";
|
|
||||||
|
|
||||||
// ---- Common parts ----
|
|
||||||
if (combined.contains("handguard") || combined.contains("rail")) return "handguard";
|
|
||||||
if (combined.contains("barrel")) return "barrel";
|
|
||||||
if (combined.contains("gas block") || combined.contains("gasblock")) return "gas-block";
|
|
||||||
if (combined.contains("gas tube") || combined.contains("gastube")) return "gas-tube";
|
|
||||||
if (combined.contains("charging handle")) return "charging-handle";
|
|
||||||
if (combined.contains("bolt carrier") || combined.contains(" bcg")) return "bcg";
|
|
||||||
if (combined.contains("magazine") || combined.contains(" mag ")) return "magazine";
|
|
||||||
if (combined.contains("stock") || combined.contains("buttstock") || combined.contains("brace")) return "stock";
|
|
||||||
if (combined.contains("pistol grip") || combined.contains(" grip")) return "grip";
|
|
||||||
if (combined.contains("trigger")) return "trigger";
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
package group.goforward.battlbuilder.services.impl;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
import group.goforward.battlbuilder.services.ReclassificationService;
|
import group.goforward.battlbuilder.services.ReclassificationService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -18,69 +17,127 @@ import java.util.Optional;
|
|||||||
@Service
|
@Service
|
||||||
public class ReclassificationServiceImpl implements ReclassificationService {
|
public class ReclassificationServiceImpl implements ReclassificationService {
|
||||||
|
|
||||||
|
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
|
||||||
|
|
||||||
public ReclassificationServiceImpl(
|
public ReclassificationServiceImpl(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantRepository merchantRepository,
|
MerchantCategoryMappingService merchantCategoryMappingService
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository
|
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||||
|
* using ONLY merchant_category_map (no rules, no inference).
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int reclassifyPendingForMerchant(Integer merchantId) {
|
public int reclassifyPendingForMerchant(Integer merchantId) {
|
||||||
// validate merchant exists (helps avoid silent failures)
|
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
|
||||||
|
|
||||||
// products that are pending for THIS merchant (via offers join in repo)
|
|
||||||
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
|
||||||
|
if (pending == null || pending.isEmpty()) return 0;
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
List<Product> toSave = new ArrayList<>();
|
||||||
int updated = 0;
|
int updated = 0;
|
||||||
|
|
||||||
for (Product p : pending) {
|
for (Product p : pending) {
|
||||||
// IMPORTANT: this assumes Product has rawCategoryKey stored (your DB does).
|
if (p.getDeletedAt() != null) continue;
|
||||||
// If your getter name differs, change this line accordingly.
|
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||||
String rawCategoryKey = p.getRawCategoryKey();
|
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<String> mappedRole = resolveMappedPartRole(merchant.getId(), rawCategoryKey);
|
String rawCategoryKey = p.getRawCategoryKey();
|
||||||
if (mappedRole.isEmpty()) {
|
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
|
||||||
continue;
|
|
||||||
}
|
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||||
|
|
||||||
|
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
|
merchantId, rawCategoryKey, platformFinal
|
||||||
|
);
|
||||||
|
if (mappedRole.isEmpty()) continue;
|
||||||
|
|
||||||
String normalized = normalizePartRole(mappedRole.get());
|
String normalized = normalizePartRole(mappedRole.get());
|
||||||
if ("unknown".equals(normalized)) {
|
if ("unknown".equals(normalized)) continue;
|
||||||
continue;
|
|
||||||
}
|
String current = normalizePartRole(p.getPartRole());
|
||||||
|
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||||
|
|
||||||
p.setPartRole(normalized);
|
p.setPartRole(normalized);
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
|
|
||||||
|
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||||
|
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||||
|
p.setClassifiedAt(now);
|
||||||
|
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||||
|
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||||
|
|
||||||
|
toSave.add(p);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<String> resolveMappedPartRole(Integer merchantId, String rawCategoryKey) {
|
/**
|
||||||
// NOTE: MerchantCategoryMap has a ManyToOne `merchant`, so we query via merchant.id traversal.
|
* Called by MappingAdminService after creating/updating a mapping.
|
||||||
List<MerchantCategoryMap> mappings =
|
* Applies mapping to all products for merchant+rawCategoryKey.
|
||||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
*/
|
||||||
merchantId, rawCategoryKey
|
@Override
|
||||||
);
|
@Transactional
|
||||||
|
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||||
|
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||||
|
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
|
||||||
|
|
||||||
return mappings.stream()
|
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
||||||
.map(MerchantCategoryMap::getPartRole)
|
if (products == null || products.isEmpty()) return 0;
|
||||||
.filter(v -> v != null && !v.isBlank())
|
|
||||||
.findFirst();
|
Instant now = Instant.now();
|
||||||
|
List<Product> toSave = new ArrayList<>();
|
||||||
|
int updated = 0;
|
||||||
|
|
||||||
|
for (Product p : products) {
|
||||||
|
if (p.getDeletedAt() != null) continue;
|
||||||
|
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||||
|
|
||||||
|
String platformFinal = normalizePlatformOrNull(p.getPlatform());
|
||||||
|
|
||||||
|
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
|
||||||
|
merchantId, rawCategoryKey, platformFinal
|
||||||
|
);
|
||||||
|
if (mappedRole.isEmpty()) continue;
|
||||||
|
|
||||||
|
String normalized = normalizePartRole(mappedRole.get());
|
||||||
|
if ("unknown".equals(normalized)) continue;
|
||||||
|
|
||||||
|
String current = normalizePartRole(p.getPartRole());
|
||||||
|
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
|
||||||
|
|
||||||
|
p.setPartRole(normalized);
|
||||||
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
|
|
||||||
|
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
|
||||||
|
p.setClassifierVersion(CLASSIFIER_VERSION);
|
||||||
|
p.setClassifiedAt(now);
|
||||||
|
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
|
||||||
|
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
|
||||||
|
|
||||||
|
toSave.add(p);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePlatformOrNull(String platform) {
|
||||||
|
if (platform == null) return null;
|
||||||
|
String t = platform.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizePartRole(String partRole) {
|
private String normalizePartRole(String partRole) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package group.goforward.battlbuilder.web.admin;
|
package group.goforward.battlbuilder.web.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
|
||||||
import group.goforward.battlbuilder.services.MappingAdminService;
|
import group.goforward.battlbuilder.services.MappingAdminService;
|
||||||
|
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ public class AdminMappingController {
|
|||||||
|
|
||||||
@GetMapping("/pending-buckets")
|
@GetMapping("/pending-buckets")
|
||||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
// Simple: just delegate to service
|
|
||||||
return mappingAdminService.listPendingBuckets();
|
return mappingAdminService.listPendingBuckets();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,15 +30,37 @@ public class AdminMappingController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@PostMapping("/apply")
|
@PostMapping("/apply")
|
||||||
public ResponseEntity<Map<String, Object>> applyMapping(
|
public ResponseEntity<Map<String, Object>> applyMapping(@RequestBody ApplyMappingRequest request) {
|
||||||
@RequestBody ApplyMappingRequest request
|
int updated = mappingAdminService.applyMapping(
|
||||||
) {
|
|
||||||
mappingAdminService.applyMapping(
|
|
||||||
request.merchantId(),
|
request.merchantId(),
|
||||||
request.rawCategoryKey(),
|
request.rawCategoryKey(),
|
||||||
request.mappedPartRole()
|
request.mappedPartRole()
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"ok", true,
|
||||||
|
"updatedProducts", updated
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ApplyToProductsRequest(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategoryKey
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual “apply mapping to products” button endpoint (nice for UI dev/testing)
|
||||||
|
*/
|
||||||
|
@PostMapping("/apply-to-products")
|
||||||
|
public ResponseEntity<Map<String, Object>> applyToProducts(@RequestBody ApplyToProductsRequest request) {
|
||||||
|
int updated = mappingAdminService.applyMappingToProducts(
|
||||||
|
request.merchantId(),
|
||||||
|
request.rawCategoryKey()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"ok", true,
|
||||||
|
"updatedProducts", updated
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,5 @@ public record PendingMappingBucketDto(
|
|||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String merchantName,
|
String merchantName,
|
||||||
String rawCategoryKey,
|
String rawCategoryKey,
|
||||||
String mappedPartRole,
|
Long productCount
|
||||||
long productCount
|
|
||||||
) {}
|
) {}
|
||||||
Reference in New Issue
Block a user