my brain is melting. reworked the categorization

This commit is contained in:
2025-12-28 13:15:16 -05:00
parent dc1c829dab
commit 5269ec479b
15 changed files with 484 additions and 384 deletions

View File

@@ -182,7 +182,7 @@
<configuration>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
<!-- <compilerArgs>&#45;&#45;enable-preview</compilerArgs>-->
</configuration>
</plugin>

View File

@@ -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; }

View File

@@ -0,0 +1,9 @@
package group.goforward.battlbuilder.model;
public enum PartRoleSource {
MERCHANT_MAP,
RULES,
INFERRED,
OVERRIDE,
UNKNOWN
}

View File

@@ -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() {

View File

@@ -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
);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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");
}
}
}

View File

@@ -2,4 +2,5 @@ package group.goforward.battlbuilder.services;
public interface ReclassificationService {
int reclassifyPendingForMerchant(Integer merchantId);
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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 (thats 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;
}
}

View File

@@ -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) {

View File

@@ -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
));
}
}

View File

@@ -4,6 +4,5 @@ public record PendingMappingBucketDto(
Integer merchantId,
String merchantName,
String rawCategoryKey,
String mappedPartRole,
long productCount
Long productCount
) {}