From 059aa7fb56336eae0188e3d08eddb253bf503b77 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 13 Dec 2025 10:07:35 -0500 Subject: [PATCH] reworked the importers category mapping. whew. --- .../classification/PartRoleResolver.java | 87 ++++++++++ .../model/MerchantCategoryMap.java | 159 ++++-------------- .../battlbuilder/model/PartRoleRule.java | 63 +++++++ .../repos/MerchantCategoryMapRepository.java | 2 +- .../repos/PartRoleRuleRepository.java | 10 ++ .../battlbuilder/repos/ProductRepository.java | 10 ++ .../services/ReclassificationService.java | 5 + .../CategoryClassificationServiceImpl.java | 120 +++++-------- .../impl/ReclassificationServiceImpl.java | 91 ++++++++++ 9 files changed, 343 insertions(+), 204 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java create mode 100644 src/main/java/group/goforward/battlbuilder/model/PartRoleRule.java create mode 100644 src/main/java/group/goforward/battlbuilder/repos/PartRoleRuleRepository.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java diff --git a/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java new file mode 100644 index 0000000..6ec0061 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/catalog/classification/PartRoleResolver.java @@ -0,0 +1,87 @@ +package group.goforward.battlbuilder.classification; + +import group.goforward.battlbuilder.model.PartRoleRule; +import group.goforward.battlbuilder.repos.PartRoleRuleRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +@Component +public class PartRoleResolver { + + private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class); + + private final PartRoleRuleRepository repo; + + private final List rules = new ArrayList<>(); + + public PartRoleResolver(PartRoleRuleRepository repo) { + this.repo = repo; + } + + @PostConstruct + public void load() { + rules.clear(); + + List active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc(); + for (PartRoleRule r : active) { + try { + rules.add(new CompiledRule( + r.getId(), + r.getTargetPlatform(), + Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE), + normalizeRole(r.getTargetPartRole()) + )); + } catch (Exception e) { + log.warn("Skipping invalid part role rule id={} regex={} err={}", + r.getId(), r.getNameRegex(), e.getMessage()); + } + } + + log.info("Loaded {} part role rules", rules.size()); + } + + public String resolve(String platform, String productName, String rawCategoryKey) { + String p = normalizePlatform(platform); + + // we match primarily on productName; optionally also include rawCategoryKey in the text blob + String text = (productName == null ? "" : productName) + + " " + + (rawCategoryKey == null ? "" : rawCategoryKey); + + for (CompiledRule r : rules) { + if (!r.appliesToPlatform(p)) continue; + if (r.pattern.matcher(text).find()) { + return r.targetPartRole; // already normalized + } + } + return null; + } + + private static String normalizeRole(String role) { + if (role == null) return null; + String t = role.trim(); + if (t.isEmpty()) return null; + return t.toLowerCase(Locale.ROOT); + } + + private static String normalizePlatform(String platform) { + if (platform == null) return null; + String t = platform.trim(); + return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); + } + + private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) { + boolean appliesToPlatform(String platform) { + if (targetPlatform == null || targetPlatform.isBlank()) return true; + if (platform == null) return false; + return targetPlatform.trim().equalsIgnoreCase(platform); + } + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java index 92889ad..fa6cc5d 100644 --- a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java +++ b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java @@ -2,17 +2,25 @@ package group.goforward.battlbuilder.model; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; -import java.math.BigDecimal; 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. + */ + @Entity -@Table(name = "merchant_category_map") +@Table(name = "merchant_category_mappings") public class MerchantCategoryMap { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -28,147 +36,44 @@ public class MerchantCategoryMap { @Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE) private String rawCategory; - @Column(name = "canonical_part_role", length = Integer.MAX_VALUE) - private String canonicalPartRole; + @Column(name = "mapped_part_role", length = Integer.MAX_VALUE) + private String mappedPartRole; - @Column(name = "confidence", precision = 5, scale = 2) - private BigDecimal confidence; - - @Column(name = "notes", length = Integer.MAX_VALUE) - private String notes; + @Column(name = "mapped_configuration", length = Integer.MAX_VALUE) + private String mappedConfiguration; @NotNull - @ColumnDefault("now()") @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @NotNull - @ColumnDefault("now()") @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; - @Size(max = 255) - @Column(name = "canonical_category") - private String canonicalCategory; - - @NotNull - @ColumnDefault("true") - @Column(name = "enabled", nullable = false) - private Boolean enabled = false; - - @Size(max = 100) - @Column(name = "platform", length = 100) - private String platform; - - @Size(max = 100) - @Column(name = "part_role", length = 100) - private String partRole; - @Column(name = "deleted_at") private OffsetDateTime deletedAt; - 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; - } + public Merchant getMerchant() { return merchant; } + public void setMerchant(Merchant merchant) { this.merchant = merchant; } - public Merchant getMerchant() { - return merchant; - } + public String getRawCategory() { return rawCategory; } + public void setRawCategory(String rawCategory) { this.rawCategory = rawCategory; } - public void setMerchant(Merchant merchant) { - this.merchant = merchant; - } + public String getMappedPartRole() { return mappedPartRole; } + public void setMappedPartRole(String mappedPartRole) { this.mappedPartRole = mappedPartRole; } - public String getRawCategory() { - return rawCategory; - } + public String getMappedConfiguration() { return mappedConfiguration; } + public void setMappedConfiguration(String mappedConfiguration) { this.mappedConfiguration = mappedConfiguration; } - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } - public String getCanonicalPartRole() { - return canonicalPartRole; - } - - public void setCanonicalPartRole(String canonicalPartRole) { - this.canonicalPartRole = canonicalPartRole; - } - - public BigDecimal getConfidence() { - return confidence; - } - - public void setConfidence(BigDecimal confidence) { - this.confidence = confidence; - } - - public String getNotes() { - return notes; - } - - public void setNotes(String notes) { - this.notes = notes; - } - - public OffsetDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(OffsetDateTime createdAt) { - this.createdAt = createdAt; - } - - public OffsetDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(OffsetDateTime updatedAt) { - this.updatedAt = updatedAt; - } - - public String getCanonicalCategory() { - return canonicalCategory; - } - - public void setCanonicalCategory(String canonicalCategory) { - this.canonicalCategory = canonicalCategory; - } - - public Boolean getEnabled() { - return enabled; - } - - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } - - public String getPlatform() { - return platform; - } - - public void setPlatform(String platform) { - this.platform = platform; - } - - public String getPartRole() { - return partRole; - } - - public void setPartRole(String partRole) { - this.partRole = partRole; - } - - public OffsetDateTime getDeletedAt() { - return deletedAt; - } - - public void setDeletedAt(OffsetDateTime deletedAt) { - this.deletedAt = deletedAt; - } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + public OffsetDateTime getDeletedAt() { return deletedAt; } + public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/PartRoleRule.java b/src/main/java/group/goforward/battlbuilder/model/PartRoleRule.java new file mode 100644 index 0000000..fd3eb8b --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/model/PartRoleRule.java @@ -0,0 +1,63 @@ +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "part_role_rules") +public class PartRoleRule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private boolean active = true; + + @Column(nullable = false) + private int priority = 0; + + @Column(name = "target_platform") + private String targetPlatform; // nullable = applies to all + + @Column(name = "name_regex", nullable = false) + private String nameRegex; + + @Column(name = "target_part_role", nullable = false) + private String targetPartRole; + + @Column + private String notes; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } + + // getters/setters + + public Long getId() { return id; } + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + + public int getPriority() { return priority; } + public void setPriority(int priority) { this.priority = priority; } + + public String getTargetPlatform() { return targetPlatform; } + public void setTargetPlatform(String targetPlatform) { this.targetPlatform = targetPlatform; } + + public String getNameRegex() { return nameRegex; } + public void setNameRegex(String nameRegex) { this.nameRegex = nameRegex; } + + public String getTargetPartRole() { return targetPartRole; } + public void setTargetPartRole(String targetPartRole) { this.targetPartRole = targetPartRole; } + + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java index 8292d47..2fc17a1 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java @@ -9,7 +9,7 @@ import java.util.List; @Repository public interface MerchantCategoryMapRepository extends JpaRepository { - List findAllByMerchantIdAndRawCategoryAndEnabledTrue( + List findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( Integer merchantId, String rawCategory ); diff --git a/src/main/java/group/goforward/battlbuilder/repos/PartRoleRuleRepository.java b/src/main/java/group/goforward/battlbuilder/repos/PartRoleRuleRepository.java new file mode 100644 index 0000000..f14a8ef --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/repos/PartRoleRuleRepository.java @@ -0,0 +1,10 @@ +package group.goforward.battlbuilder.repos; + +import group.goforward.battlbuilder.model.PartRoleRule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PartRoleRuleRepository extends JpaRepository { + List findAllByActiveTrueOrderByPriorityDescIdAsc(); +} \ 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 7049fec..9025108 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -201,4 +201,14 @@ public interface ProductRepository extends JpaRepository { @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) + List findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId); } \ 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 new file mode 100644 index 0000000..f94ed7b --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java @@ -0,0 +1,5 @@ +package group.goforward.battlbuilder.services; + +public interface ReclassificationService { + int reclassifyPendingForMerchant(Integer merchantId); +} \ 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 7282d57..d4457ca 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CategoryClassificationServiceImpl.java @@ -1,5 +1,6 @@ package group.goforward.battlbuilder.services.impl; +import group.goforward.battlbuilder.classification.PartRoleResolver; import group.goforward.battlbuilder.imports.MerchantFeedRow; import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.MerchantCategoryMap; @@ -7,7 +8,6 @@ import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository; import group.goforward.battlbuilder.services.CategoryClassificationService; import org.springframework.stereotype.Service; -import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -16,84 +16,57 @@ import java.util.Optional; public class CategoryClassificationServiceImpl implements CategoryClassificationService { private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final PartRoleResolver partRoleResolver; - public CategoryClassificationServiceImpl(MerchantCategoryMapRepository merchantCategoryMapRepository) { + public CategoryClassificationServiceImpl( + MerchantCategoryMapRepository merchantCategoryMapRepository, + PartRoleResolver partRoleResolver + ) { this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.partRoleResolver = partRoleResolver; } @Override public Result classify(Merchant merchant, MerchantFeedRow row) { String rawCategoryKey = buildRawCategoryKey(row); - // 1) Platform from mapping (if present), else infer - String platform = resolvePlatformFromMapping(merchant, rawCategoryKey) - .orElseGet(() -> inferPlatform(row)); - if (platform == null) { - platform = "AR-15"; - } + // Platform is inferred from feed/rules; mapping table does not store platform. + String platform = inferPlatform(row); + if (platform == null) platform = "AR-15"; - // 2) Part role from mapping (if present), else infer - String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKey, platform) - .orElseGet(() -> inferPartRole(row)); + final String platformFinal = platform; + final String rawCategoryKeyFinal = rawCategoryKey; - if (partRole == null || partRole.isBlank()) { - partRole = "UNKNOWN"; - } else { - partRole = partRole.trim(); - } + // Part role from mapping (if present), else rules, else infer + String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal) + .orElseGet(() -> { + String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKeyFinal); + return resolved != null ? resolved : inferPartRole(row); + }); - return new Result(platform, partRole, rawCategoryKey); + partRole = normalizePartRole(partRole); + + return new Result(platformFinal, partRole, rawCategoryKeyFinal); } - private Optional resolvePlatformFromMapping(Merchant merchant, String rawCategoryKey) { - if (rawCategoryKey == null) return Optional.empty(); + private Optional resolvePartRoleFromMapping(Merchant merchant, String rawCategoryKey) { + if (merchant == null || rawCategoryKey == null) return Optional.empty(); List mappings = - merchantCategoryMapRepository.findAllByMerchantIdAndRawCategoryAndEnabledTrue( + merchantCategoryMapRepository.findAllByMerchant_IdAndRawCategoryAndDeletedAtIsNull( merchant.getId(), rawCategoryKey ); return mappings.stream() - .sorted(Comparator - .comparing((MerchantCategoryMap m) -> m.getPlatform() == null) // exact platform last - .thenComparing(MerchantCategoryMap::getConfidence, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(MerchantCategoryMap::getId)) - .map(MerchantCategoryMap::getPlatform) - .filter(p -> p != null && !p.isBlank()) + .map(m -> m.getMappedPartRole()) + .filter(r -> r != null && !r.isBlank()) .findFirst(); } - private Optional resolvePartRoleFromMapping(Merchant merchant, - String rawCategoryKey, - String platform) { - if (rawCategoryKey == null) return Optional.empty(); - - List mappings = - merchantCategoryMapRepository.findAllByMerchantIdAndRawCategoryAndEnabledTrue( - merchant.getId(), rawCategoryKey - ); - - return mappings.stream() - .filter(m -> m.getPartRole() != null && !m.getPartRole().isBlank()) - // prefer explicit platform, but allow null platform - .sorted(Comparator - .comparing((MerchantCategoryMap m) -> !platformEquals(m.getPlatform(), platform)) - .thenComparing(MerchantCategoryMap::getConfidence, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(MerchantCategoryMap::getId)) - .map(MerchantCategoryMap::getPartRole) - .findFirst(); - } - - private boolean platformEquals(String a, String b) { - if (a == null || b == null) return false; - return a.equalsIgnoreCase(b); - } - - // You can reuse logic from MerchantFeedImportServiceImpl, but I’ll inline equivalents here private String buildRawCategoryKey(MerchantFeedRow row) { String dept = trimOrNull(row.department()); - String cat = trimOrNull(row.category()); - String sub = trimOrNull(row.subCategory()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); StringBuilder sb = new StringBuilder(); if (dept != null) sb.append(dept); @@ -105,6 +78,7 @@ public class CategoryClassificationServiceImpl implements CategoryClassification if (!sb.isEmpty()) sb.append(" > "); sb.append(sub); } + String result = sb.toString(); return result.isBlank() ? null : result; } @@ -121,6 +95,7 @@ public class CategoryClassificationServiceImpl implements CategoryClassification if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; + // default return "AR-15"; } @@ -133,31 +108,24 @@ public class CategoryClassificationServiceImpl implements CategoryClassification String lower = cat.toLowerCase(Locale.ROOT); - if (lower.contains("handguard") || lower.contains("rail")) { - return "handguard"; - } - if (lower.contains("barrel")) { - return "barrel"; - } - if (lower.contains("upper")) { - return "upper-receiver"; - } - if (lower.contains("lower")) { - return "lower-receiver"; - } - if (lower.contains("magazine") || lower.contains("mag")) { - return "magazine"; - } - if (lower.contains("stock") || lower.contains("buttstock")) { - return "stock"; - } - if (lower.contains("grip")) { - return "grip"; - } + if (lower.contains("handguard") || lower.contains("rail")) return "handguard"; + if (lower.contains("barrel")) return "barrel"; + if (lower.contains("upper")) return "upper-receiver"; + if (lower.contains("lower")) return "lower-receiver"; + if (lower.contains("magazine") || lower.contains("mag")) return "magazine"; + if (lower.contains("stock") || lower.contains("buttstock")) return "stock"; + if (lower.contains("grip")) return "grip"; return "unknown"; } + private String normalizePartRole(String partRole) { + if (partRole == null) return "unknown"; + String t = partRole.trim().toLowerCase(Locale.ROOT) + .replace('_', '-'); + return t.isBlank() ? "unknown" : t; + } + private String trimOrNull(String v) { if (v == null) return null; String t = v.trim(); diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java new file mode 100644 index 0000000..541835e --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java @@ -0,0 +1,91 @@ +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.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.util.List; +import java.util.Locale; +import java.util.Optional; + +@Service +public class ReclassificationServiceImpl implements ReclassificationService { + + private final ProductRepository productRepository; + private final MerchantRepository merchantRepository; + private final MerchantCategoryMapRepository merchantCategoryMapRepository; + + public ReclassificationServiceImpl( + ProductRepository productRepository, + MerchantRepository merchantRepository, + MerchantCategoryMapRepository merchantCategoryMapRepository + ) { + this.productRepository = productRepository; + this.merchantRepository = merchantRepository; + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + } + + @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)); + + // products that are pending for THIS merchant (via offers join in repo) + List pending = productRepository.findPendingMappingByMerchantId(merchantId); + + 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; + } + + Optional mappedRole = resolveMappedPartRole(merchant.getId(), rawCategoryKey); + if (mappedRole.isEmpty()) { + continue; + } + + String normalized = normalizePartRole(mappedRole.get()); + if ("unknown".equals(normalized)) { + continue; + } + + p.setPartRole(normalized); + p.setImportStatus(ImportStatus.MAPPED); + updated++; + } + + 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 + ); + + return mappings.stream() + .map(MerchantCategoryMap::getMappedPartRole) + .filter(v -> v != null && !v.isBlank()) + .findFirst(); + } + + private String normalizePartRole(String partRole) { + if (partRole == null) return "unknown"; + String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-'); + return t.isBlank() ? "unknown" : t; + } +} \ No newline at end of file