new categories and mapping logic

This commit is contained in:
2025-12-01 16:08:42 -05:00
parent 66d45a1113
commit f1dcd10a79
5 changed files with 98 additions and 53 deletions

View File

@@ -3,6 +3,8 @@ package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@Table( @Table(
name = "merchant_category_mappings", name = "merchant_category_mappings",
@@ -28,6 +30,10 @@ public class MerchantCategoryMapping {
@Column(name = "mapped_part_role", length = 128) @Column(name = "mapped_part_role", length = 128)
private String mappedPartRole; // e.g. "upper-receiver", "barrel" private String mappedPartRole; // e.g. "upper-receiver", "barrel"
@Column(name = "mapped_configuration")
@Enumerated(EnumType.STRING)
private ProductConfiguration mappedConfiguration;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
@@ -73,6 +79,14 @@ public class MerchantCategoryMapping {
this.mappedPartRole = mappedPartRole; this.mappedPartRole = mappedPartRole;
} }
public ProductConfiguration getMappedConfiguration() {
return mappedConfiguration;
}
public void setMappedConfiguration(ProductConfiguration mappedConfiguration) {
this.mappedConfiguration = mappedConfiguration;
}
public OffsetDateTime getCreatedAt() { public OffsetDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View File

@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@Table(name = "products") @Table(name = "products")
public class Product { public class Product {
@@ -38,6 +40,10 @@ public class Product {
@Column(name = "part_role") @Column(name = "part_role")
private String partRole; private String partRole;
@Column(name = "configuration")
@Enumerated(EnumType.STRING)
private ProductConfiguration configuration;
@Column(name = "short_description") @Column(name = "short_description")
private String shortDescription; private String shortDescription;
@@ -223,4 +229,11 @@ public class Product {
this.platformLocked = platformLocked; this.platformLocked = platformLocked;
} }
public ProductConfiguration getConfiguration() {
return configuration;
}
public void setConfiguration(ProductConfiguration configuration) {
this.configuration = configuration;
}
} }

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.model;
public enum ProductConfiguration {
STRIPPED, // bare receiver / component
ASSEMBLED, // built up but not fully complete
BARRELED, // upper + barrel + gas system, no BCG/CH
COMPLETE, // full assembly ready to run
KIT, // collection of parts (LPK, trigger kits, etc.)
OTHER // fallback / unknown
}

View File

@@ -2,6 +2,7 @@ package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.model.ProductConfiguration;
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.util.List; import java.util.List;
@@ -22,41 +23,44 @@ public class MerchantCategoryMappingService {
} }
/** /**
* Resolve a partRole for a given raw category. * Resolve (or create) a mapping row for this merchant + raw category.
* If not found, create a row with null mappedPartRole and return null (so importer can skip). * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set).
* - If it doesn't exist, creates a placeholder row with null mappings and returns it.
*
* The importer can then:
* - skip rows where mappedPartRole is still null
* - use mappedConfiguration if present
*/ */
@Transactional @Transactional
public String resolvePartRole(Merchant merchant, String rawCategory) { public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) {
if (rawCategory == null || rawCategory.isBlank()) { if (rawCategory == null || rawCategory.isBlank()) {
return null; return null;
} }
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
Optional<MerchantCategoryMapping> existingOpt = return mappingRepository
mappingRepository.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed); .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> {
if (existingOpt.isPresent()) {
return existingOpt.get().getMappedPartRole();
}
// Create placeholder row
MerchantCategoryMapping mapping = new MerchantCategoryMapping(); MerchantCategoryMapping mapping = new MerchantCategoryMapping();
mapping.setMerchant(merchant); mapping.setMerchant(merchant);
mapping.setRawCategory(trimmed); mapping.setRawCategory(trimmed);
mapping.setMappedPartRole(null); mapping.setMappedPartRole(null);
mapping.setMappedConfiguration(null);
mappingRepository.save(mapping); return mappingRepository.save(mapping);
});
// No mapping yet → importer should skip this product
return null;
} }
/** /**
* Upsert mapping (admin UI). * Upsert mapping (admin UI).
*/ */
@Transactional @Transactional
public MerchantCategoryMapping upsertMapping(Merchant merchant, String rawCategory, String mappedPartRole) { public MerchantCategoryMapping upsertMapping(
Merchant merchant,
String rawCategory,
String mappedPartRole,
ProductConfiguration mappedConfiguration
) {
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
MerchantCategoryMapping mapping = mappingRepository MerchantCategoryMapping mapping = mappingRepository
@@ -72,6 +76,21 @@ public class MerchantCategoryMappingService {
(mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
); );
mapping.setMappedConfiguration(mappedConfiguration);
return mappingRepository.save(mapping); return mappingRepository.save(mapping);
} }
/**
* Backwards-compatible overload for existing callers (e.g. controller)
* that dont care about productConfiguration yet.
*/
@Transactional
public MerchantCategoryMapping upsertMapping(
Merchant merchant,
String rawCategory,
String mappedPartRole
) {
// Delegate to the new method with `null` configuration
return upsertMapping(merchant, rawCategory, mappedPartRole, null);
}
} }

View File

@@ -22,6 +22,7 @@ import group.goforward.ballistic.repos.BrandRepository;
import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.services.MerchantCategoryMappingService; import group.goforward.ballistic.services.MerchantCategoryMappingService;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import group.goforward.ballistic.repos.ProductOfferRepository; import group.goforward.ballistic.repos.ProductOfferRepository;
@@ -190,11 +191,28 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
String rawCategoryKey = buildRawCategoryKey(row); String rawCategoryKey = buildRawCategoryKey(row);
p.setRawCategoryKey(rawCategoryKey); p.setRawCategoryKey(rawCategoryKey);
// ---------- PART ROLE ---------- // ---------- PART ROLE (via category mapping, with keyword fallback) ----------
String partRole = resolvePartRole(merchant, row); String partRole = null;
if (rawCategoryKey != null) {
// Ask the mapping service for (or to create) a mapping row
MerchantCategoryMapping mapping =
merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey);
if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) {
partRole = mapping.getMappedPartRole().trim();
}
}
// Fallback: keyword-based inference if we still don't have a mapped partRole
if (partRole == null || partRole.isBlank()) {
partRole = inferPartRole(row);
}
if (partRole == null || partRole.isBlank()) { if (partRole == null || partRole.isBlank()) {
partRole = "unknown"; partRole = "unknown";
} }
p.setPartRole(partRole); p.setPartRole(partRole);
} }
private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) {
@@ -263,35 +281,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
productOfferRepository.save(offer); productOfferRepository.save(offer);
} }
private String resolvePartRole(Merchant merchant, MerchantFeedRow row) {
// Build a merchant-specific raw category key like "Department > Category > SubCategory"
String rawCategoryKey = buildRawCategoryKey(row);
if (rawCategoryKey != null) {
// Delegate to the mapping service, which will:
// - Look up an existing mapping
// - If none exists, create a placeholder row with null mappedPartRole
// - Return the mapped partRole, or null if not yet mapped
String mapped = merchantCategoryMappingService.resolvePartRole(merchant, rawCategoryKey);
if (mapped != null && !mapped.isBlank()) {
return mapped;
}
}
// Fallback: keyword-based inference
String keywordRole = inferPartRole(row);
if (keywordRole != null && !keywordRole.isBlank()) {
return keywordRole;
}
// Last resort: log as unmapped and return null
System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName()
+ ", rawCategoryKey='" + rawCategoryKey + "'"
+ ", sku=" + row.sku()
+ ", productName=" + row.productName());
return null;
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Feed reading + brand resolution // Feed reading + brand resolution