From 5269ec479bfd4cc4a6bd87044e3e9df447c3a79c Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 28 Dec 2025 13:15:16 -0500 Subject: [PATCH] my brain is melting. reworked the categorization --- pom.xml | 2 +- .../model/MerchantCategoryMap.java | 62 ++++-- .../battlbuilder/model/PartRoleSource.java | 9 + .../goforward/battlbuilder/model/Product.java | 34 +++ .../repos/MerchantCategoryMapRepository.java | 28 ++- .../battlbuilder/repos/ProductRepository.java | 108 +++++----- .../CategoryClassificationService.java | 18 +- .../services/MappingAdminService.java | 74 ++++--- .../services/ReclassificationService.java | 1 + .../CategoryClassificationServiceImpl.java | 201 +++++------------- .../impl/MerchantCategoryMappingService.java | 38 ++++ .../impl/MerchantFeedImportServiceImpl.java | 123 ++++------- .../impl/ReclassificationServiceImpl.java | 131 ++++++++---- .../web/admin/AdminMappingController.java | 36 +++- .../web/dto/PendingMappingBucketDto.java | 3 +- 15 files changed, 484 insertions(+), 384 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/impl/MerchantCategoryMappingService.java diff --git a/pom.xml b/pom.xml index dfab9ac..1db82ea 100644 --- a/pom.xml +++ b/pom.xml @@ -182,7 +182,7 @@ 21 21 - --enable-preview + diff --git a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java index 60a82ba..95fdbce 100644 --- a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java +++ b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java @@ -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; } diff --git a/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java b/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java new file mode 100644 index 0000000..0ac701f --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/model/PartRoleSource.java @@ -0,0 +1,9 @@ +package group.goforward.battlbuilder.model; + +public enum PartRoleSource { + MERCHANT_MAP, + RULES, + INFERRED, + OVERRIDE, + UNKNOWN +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/Product.java b/src/main/java/group/goforward/battlbuilder/model/Product.java index 4244bb4..0408384 100644 --- a/src/main/java/group/goforward/battlbuilder/model/Product.java +++ b/src/main/java/group/goforward/battlbuilder/model/Product.java @@ -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 getOffers() { return offers; } public void setOffers(Set 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() { diff --git a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java index d856819..63dd5c4 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java @@ -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 { - List 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 findCanonicalPartRoles( + @Param("merchantId") Integer merchantId, + @Param("rawCategory") String rawCategory + ); + + + // Optional convenience method (you can keep, but service logic will handle platform preference) + Optional findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( Integer merchantId, String rawCategory ); - Optional findFirstByMerchant_IdAndRawCategoryAndDeletedAtIsNull( - Integer merchantId, - String rawCategory - ); + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index c0fa3e6..f2139ee 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -109,6 +109,17 @@ public interface ProductRepository extends JpaRepository { @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 findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId, + @Param("rawCategoryKey") String rawCategoryKey); + // ------------------------------------------------- // Admin import-status dashboard (summary) // ------------------------------------------------- @@ -174,64 +185,65 @@ public interface ProductRepository extends JpaRepository { ); // ------------------------------------------------- - // 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 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 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 findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); Page findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable); diff --git a/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java b/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java index 0dd42ac..a6fc9eb 100644 --- a/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java +++ b/src/main/java/group/goforward/battlbuilder/services/CategoryClassificationService.java @@ -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); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java b/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java index a0a191d..1f65639 100644 --- a/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java +++ b/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java @@ -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 listPendingBuckets() { - List rows = productRepository.findPendingMappingBuckets( - ImportStatus.PENDING_MAPPING - ); + List 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 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"); + } } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java b/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java index f94ed7b..85b2cd1 100644 --- a/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java +++ b/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java @@ -2,4 +2,5 @@ package group.goforward.battlbuilder.services; public interface ReclassificationService { int reclassifyPendingForMerchant(Integer merchantId); + int applyMappingToProducts(Integer merchantId, String rawCategoryKey); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java index fa41c6b..1a5fbaf 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java @@ -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 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 mappings = - merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( - merchant.getId(), rawCategoryKey - ); + // 1) merchant map (authoritative if present) + Optional 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(); - } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantCategoryMappingService.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantCategoryMappingService.java new file mode 100644 index 0000000..3e2ad19 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantCategoryMappingService.java @@ -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 resolveMappedPartRole( + Integer merchantId, + String rawCategoryKey, + String platformFinal + ) { + if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) { + return Optional.empty(); + } + + List canonicalRoles = + merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey); + + if (canonicalRoles == null || canonicalRoles.isEmpty()) { + return Optional.empty(); + } + + return canonicalRoles.stream() + .filter(v -> v != null && !v.isBlank()) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java index 1f58d46..1e4d25e 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java @@ -1,11 +1,9 @@ -// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted -// This is the primary ETL pipeline that ingests merchant product feeds, -// normalizes them, classifies platform/part-role, and upserts products + offers. -// -// IMPORTANT DESIGN NOTES: -// - This importer is authoritative for what ends up in the DB -// - PlatformResolver is applied HERE so unsupported products never leak downstream -// - UI, APIs, and builder assume DB data is already “clean enough” +/** + * @deprecated Legacy import flow. Prefer the newer import/reclassification pipeline that relies on: + * - merchant_category_map.canonical_part_role (authoritative) + * - PartRoleResolver rules + * - ImportStatus.PENDING_MAPPING for anything unresolved + */ package group.goforward.battlbuilder.services.impl; import group.goforward.battlbuilder.imports.MerchantFeedRow; @@ -20,6 +18,8 @@ import group.goforward.battlbuilder.repos.ProductOfferRepository; import group.goforward.battlbuilder.repos.ProductRepository; import group.goforward.battlbuilder.catalog.classification.PlatformResolver; import group.goforward.battlbuilder.services.MerchantFeedImportService; +import group.goforward.battlbuilder.services.CategoryClassificationService; + import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -36,6 +36,7 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.*; +import java.time.Instant; /** * MerchantFeedImportServiceImpl @@ -51,8 +52,8 @@ import java.util.*; * - Perfect classification (that’s iterative) * - UI-level filtering (handled later) */ +@Deprecated(forRemoval = false, since = "2025-12-28") @Service -@Transactional public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); @@ -60,24 +61,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; + // --- Classification --- // DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED) + private final CategoryClassificationService categoryClassificationService; private final PlatformResolver platformResolver; private final ProductOfferRepository productOfferRepository; - public MerchantFeedImportServiceImpl( - MerchantRepository merchantRepository, - BrandRepository brandRepository, - ProductRepository productRepository, - PlatformResolver platformResolver, - ProductOfferRepository productOfferRepository - ) { - this.merchantRepository = merchantRepository; - this.brandRepository = brandRepository; - this.productRepository = productRepository; - this.platformResolver = platformResolver; - this.productOfferRepository = productOfferRepository; - } // ========================================================================= // FULL PRODUCT + OFFER IMPORT @@ -197,7 +187,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService // ---------- NAME ---------- // Prefer productName, fallback to descriptions or SKU // - String name = coalesce( + String name = coalesce( trimOrNull(row.productName()), trimOrNull(row.shortDescription()), trimOrNull(row.longDescription()), @@ -269,31 +259,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setPlatform(finalPlatform); } + // ---------- PART ROLE (AUTHORITATIVE) ---------- +// Single source of truth: merchant map -> rules -> inference + CategoryClassificationService.Result classification = + categoryClassificationService.classify(merchant, row, p.getPlatform(), rawCategoryKey); - // ---------- PART ROLE (TEMPORARY) ---------- - // This is intentionally weak — PartRoleResolver + mappings improve this later - String partRole = inferPartRole(row); - if (partRole == null || partRole.isBlank()) { - partRole = "UNKNOWN"; - } else { - partRole = partRole.trim(); - } - p.setPartRole(partRole); +// Apply results + p.setPartRole(classification.partRole()); + p.setPartRoleSource(classification.source()); + p.setClassifierVersion("v2025-12-28.1"); + p.setClassifiedAt(Instant.now()); + p.setClassificationReason(classification.reason()); - // ---------- IMPORT STATUS ---------- - // If platform is NOT-SUPPORTED, force PENDING so it's easy to audit. +// ---------- IMPORT STATUS ---------- if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { p.setImportStatus(ImportStatus.PENDING_MAPPING); return; } - if ("UNKNOWN".equalsIgnoreCase(partRole)) { + if ("unknown".equalsIgnoreCase(classification.partRole())) { p.setImportStatus(ImportStatus.PENDING_MAPPING); } else { p.setImportStatus(ImportStatus.MAPPED); } } - // --------------------------------------------------------------------- // Offer upsert (full ETL) // --------------------------------------------------------------------- @@ -677,53 +666,19 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return "AR-15"; // safe default } - private String inferPartRole(MerchantFeedRow row) { - // Use more than just subCategory; complete uppers were being mislabeled previously. - String dept = trimOrNull(row.department()); - String cat = trimOrNull(row.category()); - String sub = trimOrNull(row.subCategory()); - String name = trimOrNull(row.productName()); - - String rawCategoryKey = buildRawCategoryKey(row); - - String combined = String.join(" ", - coalesce(rawCategoryKey, ""), - coalesce(dept, ""), - coalesce(cat, ""), - coalesce(sub, ""), - coalesce(name, "") - ).toLowerCase(Locale.ROOT); - - // ---- High priority: complete assemblies ---- - if (combined.contains("complete upper") || - combined.contains("complete uppers") || - combined.contains("upper receiver assembly") || - combined.contains("barreled upper")) { - return "complete-upper"; - } - - if (combined.contains("complete lower") || - combined.contains("complete lowers") || - combined.contains("lower receiver assembly")) { - return "complete-lower"; - } - - // ---- Receivers ---- - if (combined.contains("stripped upper")) return "upper-receiver"; - if (combined.contains("stripped lower")) return "lower-receiver"; - - // ---- Common parts ---- - if (combined.contains("handguard") || combined.contains("rail")) return "handguard"; - if (combined.contains("barrel")) return "barrel"; - if (combined.contains("gas block") || combined.contains("gasblock")) return "gas-block"; - if (combined.contains("gas tube") || combined.contains("gastube")) return "gas-tube"; - if (combined.contains("charging handle")) return "charging-handle"; - if (combined.contains("bolt carrier") || combined.contains(" bcg")) return "bcg"; - if (combined.contains("magazine") || combined.contains(" mag ")) return "magazine"; - if (combined.contains("stock") || combined.contains("buttstock") || combined.contains("brace")) return "stock"; - if (combined.contains("pistol grip") || combined.contains(" grip")) return "grip"; - if (combined.contains("trigger")) return "trigger"; - - return "unknown"; + public MerchantFeedImportServiceImpl( + MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository, + PlatformResolver platformResolver, + ProductOfferRepository productOfferRepository, + CategoryClassificationService categoryClassificationService + ) { + this.merchantRepository = merchantRepository; + this.brandRepository = brandRepository; + this.productRepository = productRepository; + this.platformResolver = platformResolver; + this.productOfferRepository = productOfferRepository; + this.categoryClassificationService = categoryClassificationService; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java index ac22f1e..bf3f5b1 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java @@ -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 pending = productRepository.findPendingMappingByMerchantId(merchantId); + if (pending == null || pending.isEmpty()) return 0; + Instant now = Instant.now(); + List 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 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 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 resolveMappedPartRole(Integer merchantId, String rawCategoryKey) { - // NOTE: MerchantCategoryMap has a ManyToOne `merchant`, so we query via merchant.id traversal. - List 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 products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey); + if (products == null || products.isEmpty()) return 0; + + Instant now = Instant.now(); + List 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 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) { diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java index 0345e4f..cc0ac36 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java @@ -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 listPendingBuckets() { - // Simple: just delegate to service return mappingAdminService.listPendingBuckets(); } @@ -32,15 +30,37 @@ public class AdminMappingController { ) {} @PostMapping("/apply") - public ResponseEntity> applyMapping( - @RequestBody ApplyMappingRequest request - ) { - mappingAdminService.applyMapping( + public ResponseEntity> 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> applyToProducts(@RequestBody ApplyToProductsRequest request) { + int updated = mappingAdminService.applyMappingToProducts( + request.merchantId(), + request.rawCategoryKey() + ); + + return ResponseEntity.ok(Map.of( + "ok", true, + "updatedProducts", updated + )); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/PendingMappingBucketDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/PendingMappingBucketDto.java index 6316836..9250f27 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/PendingMappingBucketDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/PendingMappingBucketDto.java @@ -4,6 +4,5 @@ public record PendingMappingBucketDto( Integer merchantId, String merchantName, String rawCategoryKey, - String mappedPartRole, - long productCount + Long productCount ) {} \ No newline at end of file