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> <configuration>
<source>21</source> <source>21</source>
<target>21</target> <target>21</target>
<compilerArgs>--enable-preview</compilerArgs> <!-- <compilerArgs>&#45;&#45;enable-preview</compilerArgs>-->
</configuration> </configuration>
</plugin> </plugin>

View File

@@ -6,16 +6,7 @@ import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset;
/**
* merchant_category_mappings is intentionally limited to:
* - merchant
* - raw_category
* - mapped_part_role
*
* It does NOT determine platform or confidence.
* Platform is inferred at classification time by feed/rules.
*/
@Entity @Entity
@Table(name = "merchant_category_map") @Table(name = "merchant_category_map")
@@ -36,11 +27,29 @@ public class MerchantCategoryMap {
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE) @Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
private String rawCategory; private String rawCategory;
@Column(name = "part_role", length = 255) /**
private String partRole; * Canonical role you want to classify to.
* Prefer this over partRole if present (legacy).
*/
@Column(name = "canonical_part_role", length = 255)
private String canonicalPartRole;
// @Column(name = "mapped_configuration", length = Integer.MAX_VALUE) /**
// private String mappedConfiguration; * Legacy / transitional column. Keep for now so old rows still work.
*/
// @Column(name = "part_role", length = 255)
// private String partRole;
/**
* Optional: if present, allows platform-aware mappings.
* Recommended values: "AR-15", "AR-10", "AR-9", "AK-47", or "ANY".
*/
@Column(name = "platform", length = 64)
private String platform;
@NotNull
@Column(name = "enabled", nullable = false)
private Boolean enabled = true;
@NotNull @NotNull
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
@@ -53,6 +62,20 @@ public class MerchantCategoryMap {
@Column(name = "deleted_at") @Column(name = "deleted_at")
private OffsetDateTime deletedAt; private OffsetDateTime deletedAt;
@PrePersist
public void prePersist() {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (enabled == null) enabled = true;
}
@PreUpdate
public void preUpdate() {
updatedAt = OffsetDateTime.now(ZoneOffset.UTC);
if (enabled == null) enabled = true;
}
public Integer getId() { return id; } public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; } public void setId(Integer id) { this.id = id; }
@@ -62,11 +85,14 @@ public class MerchantCategoryMap {
public String getRawCategory() { return rawCategory; } public String getRawCategory() { return rawCategory; }
public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; } public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; }
public String getPartRole() { return partRole; } public String getCanonicalPartRole() { return canonicalPartRole; }
public void setPartRole(String partRole) { this.partRole = partRole; } public void setCanonicalPartRole(String canonicalPartRole) { this.canonicalPartRole = canonicalPartRole; }
// public String getMappedConfiguration() { return mappedConfiguration; } public String getPlatform() { return platform; }
// public void setMappedConfiguration(String mappedConfiguration) { this.mappedConfiguration = mappedConfiguration; } public void setPlatform(String platform) { this.platform = platform; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }

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.Objects;
import java.util.Set; import java.util.Set;
import java.util.HashSet; import java.util.HashSet;
import group.goforward.battlbuilder.model.PartRoleSource;
@Entity @Entity
@Table(name = "products") @Table(name = "products")
@@ -61,6 +62,24 @@ public class Product {
@Column(name = "part_role") @Column(name = "part_role")
private String partRole; private String partRole;
// --- classification provenance ---
@Enumerated(EnumType.STRING)
@Column(name = "part_role_source", nullable = false)
private PartRoleSource partRoleSource = PartRoleSource.UNKNOWN;
@Column(name = "classifier_version", length = 32)
private String classifierVersion;
@Column(name = "classification_reason", length = 512)
private String classificationReason;
@Column(name = "classified_at")
private Instant classifiedAt;
@Column(name = "part_role_locked", nullable = false)
private Boolean partRoleLocked = false;
@Column(name = "configuration") @Column(name = "configuration")
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private ProductConfiguration configuration; private ProductConfiguration configuration;
@@ -203,6 +222,21 @@ public class Product {
public Set<ProductOffer> getOffers() { return offers; } public Set<ProductOffer> getOffers() { return offers; }
public void setOffers(Set<ProductOffer> offers) { this.offers = offers; } public void setOffers(Set<ProductOffer> offers) { this.offers = offers; }
public PartRoleSource getPartRoleSource() { return partRoleSource; }
public void setPartRoleSource(PartRoleSource partRoleSource) { this.partRoleSource = partRoleSource; }
public String getClassifierVersion() { return classifierVersion; }
public void setClassifierVersion(String classifierVersion) { this.classifierVersion = classifierVersion; }
public String getClassificationReason() { return classificationReason; }
public void setClassificationReason(String classificationReason) { this.classificationReason = classificationReason; }
public Instant getClassifiedAt() { return classifiedAt; }
public void setClassifiedAt(Instant classifiedAt) { this.classifiedAt = classifiedAt; }
public Boolean getPartRoleLocked() { return partRoleLocked; }
public void setPartRoleLocked(Boolean partRoleLocked) { this.partRoleLocked = partRoleLocked; }
// --- computed helpers --- // --- computed helpers ---
public BigDecimal getBestOfferPrice() { public BigDecimal getBestOfferPrice() {

View File

@@ -1,23 +1,37 @@
package group.goforward.battlbuilder.repos; package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.MerchantCategoryMap;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> { public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
List<MerchantCategoryMap> findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( @Query("""
select mcm.canonicalPartRole
from MerchantCategoryMap mcm
where mcm.merchant.id = :merchantId
and mcm.rawCategory = :rawCategory
and mcm.enabled = true
and mcm.deletedAt is null
order by mcm.updatedAt desc
""")
List<String> findCanonicalPartRoles(
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory
);
// Optional convenience method (you can keep, but service logic will handle platform preference)
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId, Integer merchantId,
String rawCategory String rawCategory
); );
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
Integer merchantId,
String rawCategory
);
} }

View File

@@ -109,6 +109,17 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
@Param("status") ImportStatus status @Param("status") ImportStatus status
); );
@Query(value = """
select distinct p.*
from products p
join product_offers po on po.product_id = p.id
where po.merchant_id = :merchantId
and p.raw_category_key = :rawCategoryKey
and p.deleted_at is null
""", nativeQuery = true)
List<Product> findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId,
@Param("rawCategoryKey") String rawCategoryKey);
// ------------------------------------------------- // -------------------------------------------------
// Admin import-status dashboard (summary) // Admin import-status dashboard (summary)
// ------------------------------------------------- // -------------------------------------------------
@@ -174,64 +185,65 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
); );
// ------------------------------------------------- // -------------------------------------------------
// Mapping admin pending buckets (all merchants) // Mapping admin pending buckets (all merchants)
// ------------------------------------------------- // Pending = no MerchantCategoryMap row exists
// -------------------------------------------------
@Query(""" @Query("""
SELECT m.id AS merchantId, SELECT m.id AS merchantId,
m.name AS merchantName, m.name AS merchantName,
p.rawCategoryKey AS rawCategoryKey, p.rawCategoryKey AS rawCategoryKey,
mcm.partRole AS mappedPartRole,
COUNT(DISTINCT p.id) AS productCount COUNT(DISTINCT p.id) AS productCount
FROM Product p FROM Product p
JOIN p.offers o JOIN p.offers o
JOIN o.merchant m JOIN o.merchant m
LEFT JOIN MerchantCategoryMap mcm LEFT JOIN MerchantCategoryMap mcm
ON mcm.merchant.id = m.id ON mcm.merchant.id = m.id
AND mcm.rawCategory = p.rawCategoryKey AND mcm.rawCategory = p.rawCategoryKey
AND mcm.deletedAt IS NULL AND mcm.deletedAt IS NULL
WHERE p.importStatus = :status WHERE p.importStatus = :status
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.partRole AND p.rawCategoryKey IS NOT NULL
ORDER BY productCount DESC GROUP BY m.id, m.name, p.rawCategoryKey
""") HAVING COUNT(mcm.id) = 0
ORDER BY productCount DESC
""")
List<Object[]> findPendingMappingBuckets(@Param("status") ImportStatus status); List<Object[]> findPendingMappingBuckets(@Param("status") ImportStatus status);
// -------------------------------------------------
// Mapping admin pending buckets for a single merchant
// ------------------------------------------------- // -------------------------------------------------
// Mapping admin pending buckets for a single merchant
// Pending = no MerchantCategoryMap row exists
// -------------------------------------------------
@Query(""" @Query("""
SELECT m.id AS merchantId, SELECT m.id AS merchantId,
m.name AS merchantName, m.name AS merchantName,
p.rawCategoryKey AS rawCategoryKey, p.rawCategoryKey AS rawCategoryKey,
mcm.partRole AS mappedPartRole,
COUNT(DISTINCT p.id) AS productCount COUNT(DISTINCT p.id) AS productCount
FROM Product p FROM Product p
JOIN p.offers o JOIN p.offers o
JOIN o.merchant m JOIN o.merchant m
LEFT JOIN MerchantCategoryMap mcm LEFT JOIN MerchantCategoryMap mcm
ON mcm.merchant.id = m.id ON mcm.merchant.id = m.id
AND mcm.rawCategory = p.rawCategoryKey AND mcm.rawCategory = p.rawCategoryKey
AND mcm.deletedAt IS NULL AND mcm.deletedAt IS NULL
WHERE p.importStatus = :status WHERE p.importStatus = :status
AND m.id = :merchantId AND m.id = :merchantId
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.partRole AND p.rawCategoryKey IS NOT NULL
ORDER BY productCount DESC GROUP BY m.id, m.name, p.rawCategoryKey
""") HAVING COUNT(mcm.id) = 0
ORDER BY productCount DESC
""")
List<Object[]> findPendingMappingBucketsForMerchant( List<Object[]> findPendingMappingBucketsForMerchant(
@Param("merchantId") Integer merchantId, @Param("merchantId") Integer merchantId,
@Param("status") ImportStatus status @Param("status") ImportStatus status
); );
@Query(value = """ @Query("""
select distinct p.* SELECT DISTINCT p
from products p FROM Product p
join product_offers po on po.product_id = p.id JOIN p.offers o
where po.merchant_id = :merchantId WHERE o.merchant.id = :merchantId
and p.import_status = 'PENDING_MAPPING' AND p.importStatus = group.goforward.battlbuilder.model.ImportStatus.PENDING_MAPPING
and p.deleted_at is null AND p.deletedAt IS NULL
""", nativeQuery = true) """)
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
Page<Product> findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable); Page<Product> findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable);

View File

@@ -2,14 +2,26 @@ package group.goforward.battlbuilder.services;
import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource;
public interface CategoryClassificationService { public interface CategoryClassificationService {
record Result( record Result(
String platform, // e.g. "AR-15" String platform,
String partRole, // e.g. "muzzle-device" String partRole,
String rawCategoryKey // e.g. "Rifle Parts > Muzzle Devices > Flash Hiders" String rawCategoryKey,
PartRoleSource source,
String reason
) {} ) {}
/**
* Legacy convenience: derives rawCategoryKey + platform from row.
*/
Result classify(Merchant merchant, MerchantFeedRow row); Result classify(Merchant merchant, MerchantFeedRow row);
/**
* Preferred for ETL: caller already computed platform + rawCategoryKey.
* This prevents platformResolver overrides from drifting vs mapping selection.
*/
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
} }

View File

@@ -18,67 +18,89 @@ public class MappingAdminService {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
private final ReclassificationService reclassificationService;
public MappingAdminService( public MappingAdminService(
ProductRepository productRepository, ProductRepository productRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository, MerchantCategoryMapRepository merchantCategoryMapRepository,
MerchantRepository merchantRepository MerchantRepository merchantRepository,
ReclassificationService reclassificationService
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
this.reclassificationService = reclassificationService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() { public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows = productRepository.findPendingMappingBuckets( List<Object[]> rows =
ImportStatus.PENDING_MAPPING productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
);
return rows.stream() return rows.stream()
.map(row -> { .map(row -> {
Integer merchantId = (Integer) row[0]; Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1]; String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2]; String rawCategoryKey = (String) row[2];
String mappedPartRole = (String) row[3]; Long count = (Long) row[3];
Long count = (Long) row[4];
return new PendingMappingBucketDto( return new PendingMappingBucketDto(
merchantId, merchantId,
merchantName, merchantName,
rawCategoryKey, rawCategoryKey,
(mappedPartRole != null && !mappedPartRole.isBlank()) ? mappedPartRole : null,
count != null ? count : 0L count != null ? count : 0L
); );
}) })
.toList(); .toList();
} }
/**
* Creates/updates the mapping row, then immediately applies it to products so the UI updates
* without requiring a re-import.
*
* @return number of products updated
*/
@Transactional @Transactional
public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) { if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|| mappedPartRole == null || mappedPartRole.isBlank()) {
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required"); throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
} }
Merchant merchant = merchantRepository.findById(merchantId) Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
List<MerchantCategoryMap> existing = MerchantCategoryMap mapping = new MerchantCategoryMap();
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull(
merchantId,
rawCategoryKey
);
MerchantCategoryMap mapping = existing.isEmpty()
? new MerchantCategoryMap()
: existing.get(0);
if (mapping.getId() == null) {
mapping.setMerchant(merchant); mapping.setMerchant(merchant);
mapping.setRawCategory(rawCategoryKey); mapping.setRawCategory(rawCategoryKey);
mapping.setEnabled(true);
// SOURCE OF TRUTH
mapping.setCanonicalPartRole(mappedPartRole.trim());
merchantCategoryMapRepository.save(mapping);
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
} }
mapping.setPartRole(mappedPartRole.trim()); /**
merchantCategoryMapRepository.save(mapping); * Manual “apply mapping to products” (no mapping row changes).
*
* @return number of products updated
*/
@Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required");
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
}
private void validateInputs(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null
|| rawCategoryKey == null || rawCategoryKey.isBlank()
|| mappedPartRole == null || mappedPartRole.isBlank()) {
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
}
} }
} }

View File

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

View File

@@ -3,71 +3,78 @@ package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver; import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMap; import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.services.CategoryClassificationService; import group.goforward.battlbuilder.services.CategoryClassificationService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class CategoryClassificationServiceImpl implements CategoryClassificationService { public class CategoryClassificationServiceImpl implements CategoryClassificationService {
private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMappingService merchantCategoryMappingService;
private final PartRoleResolver partRoleResolver; private final PartRoleResolver partRoleResolver;
public CategoryClassificationServiceImpl( public CategoryClassificationServiceImpl(
MerchantCategoryMapRepository merchantCategoryMapRepository, MerchantCategoryMappingService merchantCategoryMappingService,
PartRoleResolver partRoleResolver PartRoleResolver partRoleResolver
) { ) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMappingService = merchantCategoryMappingService;
this.partRoleResolver = partRoleResolver; this.partRoleResolver = partRoleResolver;
} }
@Override @Override
public Result classify(Merchant merchant, MerchantFeedRow row) { public Result classify(Merchant merchant, MerchantFeedRow row) {
String rawCategoryKey = buildRawCategoryKey(row); String rawCategoryKey = buildRawCategoryKey(row);
String platformFinal = inferPlatform(row);
// Platform is inferred from feed/rules; mapping table does not store platform. if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
String platform = inferPlatform(row); return classify(merchant, row, platformFinal, rawCategoryKey);
if (platform == null) platform = "AR-15";
final String platformFinal = platform;
final String rawCategoryKeyFinal = rawCategoryKey;
// Part role from mapping (if present), else rules, else infer
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal)
.orElseGet(() -> {
String resolved = partRoleResolver.resolve(
platformFinal,
row.productName(),
rawCategoryKeyFinal
);
if (resolved != null && !resolved.isBlank()) return resolved;
// ✅ IMPORTANT: pass rawCategoryKey so inference can see "Complete Uppers" etc.
return inferPartRole(row, rawCategoryKeyFinal);
});
partRole = normalizePartRole(partRole);
return new Result(platformFinal, partRole, rawCategoryKeyFinal);
} }
private Optional<String> resolvePartRoleFromMapping(Merchant merchant, String rawCategoryKey) { @Override
if (merchant == null || rawCategoryKey == null) return Optional.empty(); public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
List<MerchantCategoryMap> mappings = // 1) merchant map (authoritative if present)
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
merchant.getId(), rawCategoryKey merchant != null ? merchant.getId() : null,
rawCategoryKey,
platformFinal
); );
return mappings.stream() if (mapped.isPresent()) {
.map(MerchantCategoryMap::getPartRole) String role = normalizePartRole(mapped.get());
.filter(r -> r != null && !r.isBlank()) return new Result(
.findFirst(); platformFinal,
role,
rawCategoryKey,
PartRoleSource.MERCHANT_MAP,
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
);
}
// 2) rules
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
if (resolved != null && !resolved.isBlank()) {
String role = normalizePartRole(resolved);
return new Result(
platformFinal,
role,
rawCategoryKey,
PartRoleSource.RULES,
"PartRoleResolver matched"
);
}
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
return new Result(
platformFinal,
"unknown",
rawCategoryKey,
PartRoleSource.UNKNOWN,
"no mapping or rules match"
);
} }
private String buildRawCategoryKey(MerchantFeedRow row) { private String buildRawCategoryKey(MerchantFeedRow row) {
@@ -98,112 +105,17 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
).toLowerCase(Locale.ROOT); ).toLowerCase(Locale.ROOT);
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
if (blob.contains("ar-10") || blob.contains("ar10")) return "AR-10"; if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47"; if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
return "AR-15"; // safe default return "AR-15";
} }
/**
* Fallback inference ONLY. Prefer:
* 1) merchant mapping table
* 2) PartRoleResolver rules
* 3) this method
*
* Key principle: use rawCategoryKey + productName (not just subCategory),
* because merchants often encode the important signal in category paths.
*/
private String inferPartRole(MerchantFeedRow row, String rawCategoryKey) {
String subCat = trimOrNull(row.subCategory());
String cat = trimOrNull(row.category());
String dept = trimOrNull(row.department());
String name = trimOrNull(row.productName());
// Combine ALL possible signals
String combined = coalesce(
rawCategoryKey,
joinNonNull(" > ", dept, cat, subCat),
cat,
subCat
);
String combinedLower = combined == null ? "" : combined.toLowerCase(Locale.ROOT);
String nameLower = name == null ? "" : name.toLowerCase(Locale.ROOT);
// ---------- HIGH PRIORITY: COMPLETE ASSEMBLIES ----------
// rawCategoryKey from your DB shows "Ar-15 Complete Uppers" — grab that first.
boolean looksLikeCompleteUpper =
combinedLower.contains("complete upper") ||
combinedLower.contains("complete uppers") ||
combinedLower.contains("upper receiver assembly") ||
combinedLower.contains("barreled upper") ||
nameLower.contains("complete upper") ||
nameLower.contains("complete upper receiver") ||
nameLower.contains("barreled upper") ||
nameLower.contains("upper receiver assembly");
if (looksLikeCompleteUpper) return "complete-upper";
boolean looksLikeCompleteLower =
combinedLower.contains("complete lower") ||
combinedLower.contains("complete lowers") ||
nameLower.contains("complete lower");
if (looksLikeCompleteLower) return "complete-lower";
// ---------- RECEIVERS ----------
// If we see "stripped upper", prefer upper-receiver. Otherwise "upper" can be generic.
boolean looksLikeStrippedUpper =
combinedLower.contains("stripped upper") ||
nameLower.contains("stripped upper");
if (looksLikeStrippedUpper) return "upper-receiver";
boolean looksLikeStrippedLower =
combinedLower.contains("stripped lower") ||
nameLower.contains("stripped lower");
if (looksLikeStrippedLower) return "lower-receiver";
// ---------- COMMON PARTS ----------
if (combinedLower.contains("handguard") || combinedLower.contains("rail")) return "handguard";
if (combinedLower.contains("barrel")) return "barrel";
if (combinedLower.contains("gas block") || combinedLower.contains("gas-block") || combinedLower.contains("gasblock")) return "gas-block";
if (combinedLower.contains("gas tube") || combinedLower.contains("gas-tube") || combinedLower.contains("gastube")) return "gas-tube";
if (combinedLower.contains("muzzle") || combinedLower.contains("brake") || combinedLower.contains("compensator")) return "muzzle-device";
if (combinedLower.contains("bolt carrier") || combinedLower.contains("bolt-carrier") || combinedLower.contains("bcg")) return "bcg";
if (combinedLower.contains("charging handle") || combinedLower.contains("charging-handle")) return "charging-handle";
if (combinedLower.contains("lower parts") || combinedLower.contains("lower-parts") || combinedLower.contains("lpk")) return "lower-parts";
if (combinedLower.contains("trigger")) return "trigger";
if (combinedLower.contains("pistol grip") || combinedLower.contains("grip")) return "grip";
if (combinedLower.contains("safety") || combinedLower.contains("selector")) return "safety";
if (combinedLower.contains("buffer")) return "buffer";
if (combinedLower.contains("stock") || combinedLower.contains("buttstock") || combinedLower.contains("brace")) return "stock";
if (combinedLower.contains("magazine") || combinedLower.contains(" mag ") || combinedLower.equals("mag")) return "magazine";
if (combinedLower.contains("sight")) return "sights";
if (combinedLower.contains("optic") || combinedLower.contains("scope")) return "optic";
if (combinedLower.contains("suppress")) return "suppressor";
if (combinedLower.contains("light") || combinedLower.contains("laser")) return "weapon-light";
if (combinedLower.contains("bipod")) return "bipod";
if (combinedLower.contains("sling")) return "sling";
if (combinedLower.contains("foregrip") || combinedLower.contains("vertical grip") || combinedLower.contains("angled")) return "foregrip";
if (combinedLower.contains("tool") || combinedLower.contains("wrench") || combinedLower.contains("armorer")) return "tools";
// ---------- LAST RESORT ----------
// If it says "upper" but NOT complete upper, keep it generic
if (combinedLower.contains("upper")) return "upper";
if (combinedLower.contains("lower")) return "lower";
return "unknown";
}
private String normalizePartRole(String partRole) { private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown"; if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT) String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
.replace('_', '-');
return t.isBlank() ? "unknown" : t; return t.isBlank() ? "unknown" : t;
} }
@@ -220,15 +132,4 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
} }
return null; return null;
} }
private String joinNonNull(String sep, String... parts) {
if (parts == null || parts.length == 0) return null;
StringBuilder sb = new StringBuilder();
for (String p : parts) {
if (p == null || p.isBlank()) continue;
if (!sb.isEmpty()) sb.append(sep);
sb.append(p.trim());
}
return sb.isEmpty() ? null : sb.toString();
}
} }

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, * @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on:
// normalizes them, classifies platform/part-role, and upserts products + offers. * - merchant_category_map.canonical_part_role (authoritative)
// * - PartRoleResolver rules
// IMPORTANT DESIGN NOTES: * - ImportStatus.PENDING_MAPPING for anything unresolved
// - This importer is authoritative for what ends up in the DB */
// - PlatformResolver is applied HERE so unsupported products never leak downstream
// - UI, APIs, and builder assume DB data is already “clean enough”
package group.goforward.battlbuilder.services.impl; package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.imports.MerchantFeedRow;
@@ -20,6 +18,8 @@ import group.goforward.battlbuilder.repos.ProductOfferRepository;
import group.goforward.battlbuilder.repos.ProductRepository; import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.catalog.classification.PlatformResolver; import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
import group.goforward.battlbuilder.services.MerchantFeedImportService; import group.goforward.battlbuilder.services.MerchantFeedImportService;
import group.goforward.battlbuilder.services.CategoryClassificationService;
import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord; import org.apache.commons.csv.CSVRecord;
@@ -36,6 +36,7 @@ import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.*; import java.util.*;
import java.time.Instant;
/** /**
* MerchantFeedImportServiceImpl * MerchantFeedImportServiceImpl
@@ -51,8 +52,8 @@ import java.util.*;
* - Perfect classification (thats iterative) * - Perfect classification (thats iterative)
* - UI-level filtering (handled later) * - UI-level filtering (handled later)
*/ */
@Deprecated(forRemoval = false, since = "2025-12-28")
@Service @Service
@Transactional
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
@@ -60,24 +61,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
private final BrandRepository brandRepository; private final BrandRepository brandRepository;
private final ProductRepository productRepository; private final ProductRepository productRepository;
// --- Classification --- // --- Classification ---
// DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED) // DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED)
private final CategoryClassificationService categoryClassificationService;
private final PlatformResolver platformResolver; private final PlatformResolver platformResolver;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public MerchantFeedImportServiceImpl(
MerchantRepository merchantRepository,
BrandRepository brandRepository,
ProductRepository productRepository,
PlatformResolver platformResolver,
ProductOfferRepository productOfferRepository
) {
this.merchantRepository = merchantRepository;
this.brandRepository = brandRepository;
this.productRepository = productRepository;
this.platformResolver = platformResolver;
this.productOfferRepository = productOfferRepository;
}
// ========================================================================= // =========================================================================
// FULL PRODUCT + OFFER IMPORT // FULL PRODUCT + OFFER IMPORT
@@ -269,31 +259,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setPlatform(finalPlatform); p.setPlatform(finalPlatform);
} }
// ---------- PART ROLE (AUTHORITATIVE) ----------
// Single source of truth: merchant map -> rules -> inference
CategoryClassificationService.Result classification =
categoryClassificationService.classify(merchant, row, p.getPlatform(), rawCategoryKey);
// ---------- PART ROLE (TEMPORARY) ---------- // Apply results
// This is intentionally weak — PartRoleResolver + mappings improve this later p.setPartRole(classification.partRole());
String partRole = inferPartRole(row); p.setPartRoleSource(classification.source());
if (partRole == null || partRole.isBlank()) { p.setClassifierVersion("v2025-12-28.1");
partRole = "UNKNOWN"; p.setClassifiedAt(Instant.now());
} else { p.setClassificationReason(classification.reason());
partRole = partRole.trim();
}
p.setPartRole(partRole);
// ---------- IMPORT STATUS ---------- // ---------- IMPORT STATUS ----------
// If platform is NOT-SUPPORTED, force PENDING so it's easy to audit.
if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) {
p.setImportStatus(ImportStatus.PENDING_MAPPING); p.setImportStatus(ImportStatus.PENDING_MAPPING);
return; return;
} }
if ("UNKNOWN".equalsIgnoreCase(partRole)) { if ("unknown".equalsIgnoreCase(classification.partRole())) {
p.setImportStatus(ImportStatus.PENDING_MAPPING); p.setImportStatus(ImportStatus.PENDING_MAPPING);
} else { } else {
p.setImportStatus(ImportStatus.MAPPED); p.setImportStatus(ImportStatus.MAPPED);
} }
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Offer upsert (full ETL) // Offer upsert (full ETL)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -677,53 +666,19 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return "AR-15"; // safe default return "AR-15"; // safe default
} }
private String inferPartRole(MerchantFeedRow row) { public MerchantFeedImportServiceImpl(
// Use more than just subCategory; complete uppers were being mislabeled previously. MerchantRepository merchantRepository,
String dept = trimOrNull(row.department()); BrandRepository brandRepository,
String cat = trimOrNull(row.category()); ProductRepository productRepository,
String sub = trimOrNull(row.subCategory()); PlatformResolver platformResolver,
String name = trimOrNull(row.productName()); ProductOfferRepository productOfferRepository,
CategoryClassificationService categoryClassificationService
String rawCategoryKey = buildRawCategoryKey(row); ) {
this.merchantRepository = merchantRepository;
String combined = String.join(" ", this.brandRepository = brandRepository;
coalesce(rawCategoryKey, ""), this.productRepository = productRepository;
coalesce(dept, ""), this.platformResolver = platformResolver;
coalesce(cat, ""), this.productOfferRepository = productOfferRepository;
coalesce(sub, ""), this.categoryClassificationService = categoryClassificationService;
coalesce(name, "")
).toLowerCase(Locale.ROOT);
// ---- High priority: complete assemblies ----
if (combined.contains("complete upper") ||
combined.contains("complete uppers") ||
combined.contains("upper receiver assembly") ||
combined.contains("barreled upper")) {
return "complete-upper";
}
if (combined.contains("complete lower") ||
combined.contains("complete lowers") ||
combined.contains("lower receiver assembly")) {
return "complete-lower";
}
// ---- Receivers ----
if (combined.contains("stripped upper")) return "upper-receiver";
if (combined.contains("stripped lower")) return "lower-receiver";
// ---- Common parts ----
if (combined.contains("handguard") || combined.contains("rail")) return "handguard";
if (combined.contains("barrel")) return "barrel";
if (combined.contains("gas block") || combined.contains("gasblock")) return "gas-block";
if (combined.contains("gas tube") || combined.contains("gastube")) return "gas-tube";
if (combined.contains("charging handle")) return "charging-handle";
if (combined.contains("bolt carrier") || combined.contains(" bcg")) return "bcg";
if (combined.contains("magazine") || combined.contains(" mag ")) return "magazine";
if (combined.contains("stock") || combined.contains("buttstock") || combined.contains("brace")) return "stock";
if (combined.contains("pistol grip") || combined.contains(" grip")) return "grip";
if (combined.contains("trigger")) return "trigger";
return "unknown";
} }
} }

View File

@@ -1,16 +1,15 @@
package group.goforward.battlbuilder.services.impl; package group.goforward.battlbuilder.services.impl;
import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.Product; import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repos.MerchantRepository;
import group.goforward.battlbuilder.repos.ProductRepository; import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.services.ReclassificationService; import group.goforward.battlbuilder.services.ReclassificationService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@@ -18,69 +17,127 @@ import java.util.Optional;
@Service @Service
public class ReclassificationServiceImpl implements ReclassificationService { public class ReclassificationServiceImpl implements ReclassificationService {
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final MerchantRepository merchantRepository; private final MerchantCategoryMappingService merchantCategoryMappingService;
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public ReclassificationServiceImpl( public ReclassificationServiceImpl(
ProductRepository productRepository, ProductRepository productRepository,
MerchantRepository merchantRepository, MerchantCategoryMappingService merchantCategoryMappingService
MerchantCategoryMapRepository merchantCategoryMapRepository
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantRepository = merchantRepository; this.merchantCategoryMappingService = merchantCategoryMappingService;
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
} }
/**
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
* using ONLY merchant_category_map (no rules, no inference).
*/
@Override @Override
@Transactional @Transactional
public int reclassifyPendingForMerchant(Integer merchantId) { public int reclassifyPendingForMerchant(Integer merchantId) {
// validate merchant exists (helps avoid silent failures) if (merchantId == null) throw new IllegalArgumentException("merchantId required");
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// products that are pending for THIS merchant (via offers join in repo)
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId); List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
if (pending == null || pending.isEmpty()) return 0;
Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0; int updated = 0;
for (Product p : pending) { for (Product p : pending) {
// IMPORTANT: this assumes Product has rawCategoryKey stored (your DB does). if (p.getDeletedAt() != null) continue;
// If your getter name differs, change this line accordingly. if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String rawCategoryKey = p.getRawCategoryKey();
if (rawCategoryKey == null || rawCategoryKey.isBlank()) {
continue;
}
Optional<String> mappedRole = resolveMappedPartRole(merchant.getId(), rawCategoryKey); String rawCategoryKey = p.getRawCategoryKey();
if (mappedRole.isEmpty()) { if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
continue;
} String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get()); String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) { if ("unknown".equals(normalized)) continue;
continue;
} String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized); p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED); p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++; updated++;
} }
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated; return updated;
} }
private Optional<String> resolveMappedPartRole(Integer merchantId, String rawCategoryKey) { /**
// NOTE: MerchantCategoryMap has a ManyToOne `merchant`, so we query via merchant.id traversal. * Called by MappingAdminService after creating/updating a mapping.
List<MerchantCategoryMap> mappings = * Applies mapping to all products for merchant+rawCategoryKey.
merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( */
merchantId, rawCategoryKey @Override
); @Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
return mappings.stream() List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
.map(MerchantCategoryMap::getPartRole) if (products == null || products.isEmpty()) return 0;
.filter(v -> v != null && !v.isBlank())
.findFirst(); Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0;
for (Product p : products) {
if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++;
}
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated;
}
private String normalizePlatformOrNull(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t;
} }
private String normalizePartRole(String partRole) { private String normalizePartRole(String partRole) {

View File

@@ -1,8 +1,7 @@
package group.goforward.battlbuilder.web.admin; package group.goforward.battlbuilder.web.admin;
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
import group.goforward.battlbuilder.services.MappingAdminService; import group.goforward.battlbuilder.services.MappingAdminService;
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -21,7 +20,6 @@ public class AdminMappingController {
@GetMapping("/pending-buckets") @GetMapping("/pending-buckets")
public List<PendingMappingBucketDto> listPendingBuckets() { public List<PendingMappingBucketDto> listPendingBuckets() {
// Simple: just delegate to service
return mappingAdminService.listPendingBuckets(); return mappingAdminService.listPendingBuckets();
} }
@@ -32,15 +30,37 @@ public class AdminMappingController {
) {} ) {}
@PostMapping("/apply") @PostMapping("/apply")
public ResponseEntity<Map<String, Object>> applyMapping( public ResponseEntity<Map<String, Object>> applyMapping(@RequestBody ApplyMappingRequest request) {
@RequestBody ApplyMappingRequest request int updated = mappingAdminService.applyMapping(
) {
mappingAdminService.applyMapping(
request.merchantId(), request.merchantId(),
request.rawCategoryKey(), request.rawCategoryKey(),
request.mappedPartRole() request.mappedPartRole()
); );
return ResponseEntity.ok(Map.of("ok", true)); return ResponseEntity.ok(Map.of(
"ok", true,
"updatedProducts", updated
));
}
public record ApplyToProductsRequest(
Integer merchantId,
String rawCategoryKey
) {}
/**
* Manual “apply mapping to products” button endpoint (nice for UI dev/testing)
*/
@PostMapping("/apply-to-products")
public ResponseEntity<Map<String, Object>> applyToProducts(@RequestBody ApplyToProductsRequest request) {
int updated = mappingAdminService.applyMappingToProducts(
request.merchantId(),
request.rawCategoryKey()
);
return ResponseEntity.ok(Map.of(
"ok", true,
"updatedProducts", updated
));
} }
} }

View File

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