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>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<compilerArgs>--enable-preview</compilerArgs>
|
||||
<!-- <compilerArgs>--enable-preview</compilerArgs>-->
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.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<ProductOffer> getOffers() { return 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 ---
|
||||
|
||||
public BigDecimal getBestOfferPrice() {
|
||||
|
||||
@@ -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<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,
|
||||
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
|
||||
);
|
||||
|
||||
@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)
|
||||
// -------------------------------------------------
|
||||
@@ -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("""
|
||||
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<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("""
|
||||
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
|
||||
AND m.id = :merchantId
|
||||
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 m.id = :merchantId
|
||||
AND p.rawCategoryKey IS NOT NULL
|
||||
GROUP BY m.id, m.name, p.rawCategoryKey
|
||||
HAVING COUNT(mcm.id) = 0
|
||||
ORDER BY productCount DESC
|
||||
""")
|
||||
List<Object[]> findPendingMappingBucketsForMerchant(
|
||||
@Param("merchantId") Integer merchantId,
|
||||
@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.import_status = 'PENDING_MAPPING'
|
||||
and p.deleted_at is null
|
||||
""", nativeQuery = true)
|
||||
|
||||
|
||||
@Query("""
|
||||
SELECT DISTINCT p
|
||||
FROM Product p
|
||||
JOIN p.offers o
|
||||
WHERE o.merchant.id = :merchantId
|
||||
AND p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING
|
||||
AND p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
||||
|
||||
Page<Product> findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable);
|
||||
|
||||
@@ -2,14 +2,26 @@ package group.goforward.battlbuilder.services;
|
||||
|
||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
|
||||
public interface CategoryClassificationService {
|
||||
|
||||
record Result(
|
||||
String platform, // e.g. "AR-15"
|
||||
String partRole, // e.g. "muzzle-device"
|
||||
String rawCategoryKey // e.g. "Rifle Parts > Muzzle Devices > Flash Hiders"
|
||||
String platform,
|
||||
String partRole,
|
||||
String rawCategoryKey,
|
||||
PartRoleSource source,
|
||||
String reason
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Legacy convenience: derives rawCategoryKey + platform from 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 MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final ReclassificationService reclassificationService;
|
||||
|
||||
public MappingAdminService(
|
||||
ProductRepository productRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
MerchantRepository merchantRepository
|
||||
MerchantRepository merchantRepository,
|
||||
ReclassificationService reclassificationService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.reclassificationService = reclassificationService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||
List<Object[]> rows = productRepository.findPendingMappingBuckets(
|
||||
ImportStatus.PENDING_MAPPING
|
||||
);
|
||||
List<Object[]> rows =
|
||||
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
|
||||
|
||||
return rows.stream()
|
||||
.map(row -> {
|
||||
Integer merchantId = (Integer) row[0];
|
||||
String merchantName = (String) row[1];
|
||||
String rawCategoryKey = (String) row[2];
|
||||
String mappedPartRole = (String) row[3];
|
||||
Long count = (Long) row[4];
|
||||
Integer merchantId = (Integer) row[0];
|
||||
String merchantName = (String) row[1];
|
||||
String rawCategoryKey = (String) row[2];
|
||||
Long count = (Long) row[3];
|
||||
|
||||
return new PendingMappingBucketDto(
|
||||
merchantId,
|
||||
merchantName,
|
||||
rawCategoryKey,
|
||||
(mappedPartRole != null && !mappedPartRole.isBlank()) ? mappedPartRole : null,
|
||||
count != null ? count : 0L
|
||||
);
|
||||
})
|
||||
.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
|
||||
public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||
if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) {
|
||||
public int applyMapping(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");
|
||||
}
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
List<MerchantCategoryMap> existing =
|
||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
||||
merchantId,
|
||||
rawCategoryKey
|
||||
);
|
||||
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||
mapping.setMerchant(merchant);
|
||||
mapping.setRawCategory(rawCategoryKey);
|
||||
mapping.setEnabled(true);
|
||||
|
||||
MerchantCategoryMap mapping = existing.isEmpty()
|
||||
? new MerchantCategoryMap()
|
||||
: existing.get(0);
|
||||
// SOURCE OF TRUTH
|
||||
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||
|
||||
if (mapping.getId() == null) {
|
||||
mapping.setMerchant(merchant);
|
||||
mapping.setRawCategory(rawCategoryKey);
|
||||
}
|
||||
|
||||
mapping.setPartRole(mappedPartRole.trim());
|
||||
merchantCategoryMapRepository.save(mapping);
|
||||
|
||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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.imports.MerchantFeedRow;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
|
||||
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
private final PartRoleResolver partRoleResolver;
|
||||
|
||||
public CategoryClassificationServiceImpl(
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||
PartRoleResolver partRoleResolver
|
||||
) {
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
this.partRoleResolver = partRoleResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row) {
|
||||
String rawCategoryKey = buildRawCategoryKey(row);
|
||||
|
||||
// Platform is inferred from feed/rules; mapping table does not store platform.
|
||||
String platform = inferPlatform(row);
|
||||
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);
|
||||
String platformFinal = inferPlatform(row);
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
return classify(merchant, row, platformFinal, rawCategoryKey);
|
||||
}
|
||||
|
||||
private Optional<String> resolvePartRoleFromMapping(Merchant merchant, String rawCategoryKey) {
|
||||
if (merchant == null || rawCategoryKey == null) return Optional.empty();
|
||||
@Override
|
||||
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
|
||||
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
|
||||
|
||||
List<MerchantCategoryMap> mappings =
|
||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
||||
merchant.getId(), rawCategoryKey
|
||||
);
|
||||
// 1) merchant map (authoritative if present)
|
||||
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
|
||||
merchant != null ? merchant.getId() : null,
|
||||
rawCategoryKey,
|
||||
platformFinal
|
||||
);
|
||||
|
||||
return mappings.stream()
|
||||
.map(MerchantCategoryMap::getPartRole)
|
||||
.filter(r -> r != null && !r.isBlank())
|
||||
.findFirst();
|
||||
if (mapped.isPresent()) {
|
||||
String role = normalizePartRole(mapped.get());
|
||||
return new Result(
|
||||
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) {
|
||||
@@ -98,112 +105,17 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
).toLowerCase(Locale.ROOT);
|
||||
|
||||
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("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) {
|
||||
if (partRole == null) return "unknown";
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT)
|
||||
.replace('_', '-');
|
||||
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
return t.isBlank() ? "unknown" : t;
|
||||
}
|
||||
|
||||
@@ -220,15 +132,4 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
||||
}
|
||||
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,
|
||||
// normalizes them, classifies platform/part-role, and upserts products + offers.
|
||||
//
|
||||
// IMPORTANT DESIGN NOTES:
|
||||
// - 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”
|
||||
/**
|
||||
* @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on:
|
||||
* - merchant_category_map.canonical_part_role (authoritative)
|
||||
* - PartRoleResolver rules
|
||||
* - ImportStatus.PENDING_MAPPING for anything unresolved
|
||||
*/
|
||||
package group.goforward.battlbuilder.services.impl;
|
||||
|
||||
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.catalog.classification.PlatformResolver;
|
||||
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
@@ -36,6 +36,7 @@ import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* MerchantFeedImportServiceImpl
|
||||
@@ -51,8 +52,8 @@ import java.util.*;
|
||||
* - Perfect classification (that’s iterative)
|
||||
* - UI-level filtering (handled later)
|
||||
*/
|
||||
@Deprecated(forRemoval = false, since = "2025-12-28")
|
||||
@Service
|
||||
@Transactional
|
||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||
@@ -60,24 +61,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final BrandRepository brandRepository;
|
||||
private final ProductRepository productRepository;
|
||||
|
||||
// --- Classification ---
|
||||
// DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED)
|
||||
private final CategoryClassificationService categoryClassificationService;
|
||||
private final PlatformResolver platformResolver;
|
||||
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
|
||||
@@ -197,7 +187,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
// ---------- NAME ----------
|
||||
// Prefer productName, fallback to descriptions or SKU
|
||||
//
|
||||
String name = coalesce(
|
||||
String name = coalesce(
|
||||
trimOrNull(row.productName()),
|
||||
trimOrNull(row.shortDescription()),
|
||||
trimOrNull(row.longDescription()),
|
||||
@@ -269,31 +259,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
|
||||
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) ----------
|
||||
// This is intentionally weak — PartRoleResolver + mappings improve this later
|
||||
String partRole = inferPartRole(row);
|
||||
if (partRole == null || partRole.isBlank()) {
|
||||
partRole = "UNKNOWN";
|
||||
} else {
|
||||
partRole = partRole.trim();
|
||||
}
|
||||
p.setPartRole(partRole);
|
||||
// Apply results
|
||||
p.setPartRole(classification.partRole());
|
||||
p.setPartRoleSource(classification.source());
|
||||
p.setClassifierVersion("v2025-12-28.1");
|
||||
p.setClassifiedAt(Instant.now());
|
||||
p.setClassificationReason(classification.reason());
|
||||
|
||||
// ---------- IMPORT STATUS ----------
|
||||
// If platform is NOT-SUPPORTED, force PENDING so it's easy to audit.
|
||||
// ---------- IMPORT STATUS ----------
|
||||
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
|
||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("UNKNOWN".equalsIgnoreCase(partRole)) {
|
||||
if ("unknown".equalsIgnoreCase(classification.partRole())) {
|
||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||
} else {
|
||||
p.setImportStatus(ImportStatus.MAPPED);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Offer upsert (full ETL)
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -677,53 +666,19 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
return "AR-15"; // safe default
|
||||
}
|
||||
|
||||
private String inferPartRole(MerchantFeedRow row) {
|
||||
// Use more than just subCategory; complete uppers were being mislabeled previously.
|
||||
String dept = trimOrNull(row.department());
|
||||
String cat = trimOrNull(row.category());
|
||||
String sub = trimOrNull(row.subCategory());
|
||||
String name = trimOrNull(row.productName());
|
||||
|
||||
String rawCategoryKey = buildRawCategoryKey(row);
|
||||
|
||||
String combined = String.join(" ",
|
||||
coalesce(rawCategoryKey, ""),
|
||||
coalesce(dept, ""),
|
||||
coalesce(cat, ""),
|
||||
coalesce(sub, ""),
|
||||
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";
|
||||
public MerchantFeedImportServiceImpl(
|
||||
MerchantRepository merchantRepository,
|
||||
BrandRepository brandRepository,
|
||||
ProductRepository productRepository,
|
||||
PlatformResolver platformResolver,
|
||||
ProductOfferRepository productOfferRepository,
|
||||
CategoryClassificationService categoryClassificationService
|
||||
) {
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.brandRepository = brandRepository;
|
||||
this.productRepository = productRepository;
|
||||
this.platformResolver = platformResolver;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
this.categoryClassificationService = categoryClassificationService;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package group.goforward.battlbuilder.services.impl;
|
||||
|
||||
import group.goforward.battlbuilder.model.ImportStatus;
|
||||
import group.goforward.battlbuilder.model.Merchant;
|
||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||
import group.goforward.battlbuilder.model.PartRoleSource;
|
||||
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.services.ReclassificationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
@@ -18,69 +17,127 @@ import java.util.Optional;
|
||||
@Service
|
||||
public class ReclassificationServiceImpl implements ReclassificationService {
|
||||
|
||||
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||
|
||||
public ReclassificationServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
MerchantRepository merchantRepository,
|
||||
MerchantCategoryMapRepository merchantCategoryMapRepository
|
||||
MerchantCategoryMappingService merchantCategoryMappingService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.merchantRepository = merchantRepository;
|
||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||
* using ONLY merchant_category_map (no rules, no inference).
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int reclassifyPendingForMerchant(Integer merchantId) {
|
||||
// validate merchant exists (helps avoid silent failures)
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
|
||||
|
||||
// products that are pending for THIS merchant (via offers join in repo)
|
||||
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;
|
||||
|
||||
for (Product p : pending) {
|
||||
// IMPORTANT: this assumes Product has rawCategoryKey stored (your DB does).
|
||||
// If your getter name differs, change this line accordingly.
|
||||
String rawCategoryKey = p.getRawCategoryKey();
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (p.getDeletedAt() != null) continue;
|
||||
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
|
||||
|
||||
Optional<String> mappedRole = resolveMappedPartRole(merchant.getId(), rawCategoryKey);
|
||||
if (mappedRole.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String rawCategoryKey = p.getRawCategoryKey();
|
||||
if (rawCategoryKey == null || rawCategoryKey.isBlank()) 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;
|
||||
}
|
||||
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 Optional<String> resolveMappedPartRole(Integer merchantId, String rawCategoryKey) {
|
||||
// NOTE: MerchantCategoryMap has a ManyToOne `merchant`, so we query via merchant.id traversal.
|
||||
List<MerchantCategoryMap> mappings =
|
||||
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
|
||||
merchantId, rawCategoryKey
|
||||
);
|
||||
/**
|
||||
* Called by MappingAdminService after creating/updating a mapping.
|
||||
* Applies mapping to all products for merchant+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()
|
||||
.map(MerchantCategoryMap::getPartRole)
|
||||
.filter(v -> v != null && !v.isBlank())
|
||||
.findFirst();
|
||||
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
|
||||
if (products == null || products.isEmpty()) return 0;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package group.goforward.battlbuilder.web.admin;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||
import group.goforward.battlbuilder.services.MappingAdminService;
|
||||
|
||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -21,7 +20,6 @@ public class AdminMappingController {
|
||||
|
||||
@GetMapping("/pending-buckets")
|
||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||
// Simple: just delegate to service
|
||||
return mappingAdminService.listPendingBuckets();
|
||||
}
|
||||
|
||||
@@ -32,15 +30,37 @@ public class AdminMappingController {
|
||||
) {}
|
||||
|
||||
@PostMapping("/apply")
|
||||
public ResponseEntity<Map<String, Object>> applyMapping(
|
||||
@RequestBody ApplyMappingRequest request
|
||||
) {
|
||||
mappingAdminService.applyMapping(
|
||||
public ResponseEntity<Map<String, Object>> applyMapping(@RequestBody ApplyMappingRequest request) {
|
||||
int updated = mappingAdminService.applyMapping(
|
||||
request.merchantId(),
|
||||
request.rawCategoryKey(),
|
||||
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,
|
||||
String merchantName,
|
||||
String rawCategoryKey,
|
||||
String mappedPartRole,
|
||||
long productCount
|
||||
Long productCount
|
||||
) {}
|
||||
Reference in New Issue
Block a user