diff --git a/src/main/java/group/goforward/battlbuilder/classification/ClassificationResult.java b/src/main/java/group/goforward/battlbuilder/classification/ClassificationResult.java new file mode 100644 index 0000000..b9ef55f --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/ClassificationResult.java @@ -0,0 +1,16 @@ +package group.goforward.battlbuilder.classification; + +import java.util.Map; + +public record ClassificationResult( + String partRole, + String source, + String reason, + String classifierVersion, + double confidence, + Map meta +) { + public static ClassificationResult unknown(String version, String reason) { + return new ClassificationResult(null, "unknown", reason, version, 0.0, Map.of()); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/ProductClassifier.java b/src/main/java/group/goforward/battlbuilder/classification/ProductClassifier.java new file mode 100644 index 0000000..f1549bf --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/ProductClassifier.java @@ -0,0 +1,30 @@ +package group.goforward.battlbuilder.classification; + +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.Product; + +import java.util.Map; +import java.util.Optional; + +public interface ProductClassifier { + /** + * Pure classification: determines the canonical part role for a product. + * Must NOT persist or mutate state. + */ + ClassificationResult classifyProduct(Product product); + + ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId); + + /** + * Optional fast-path: if caller already knows the resolved merchantId, + * pass it to avoid per-product DB lookups (prevents N+1 in reconcile). + */ + default ClassificationResult classifyProduct( + Product product, + Integer resolvedMerchantId, + Map> mappingMemo + ) { + // Default fallback so existing implementations compile + return classifyProduct(product, resolvedMerchantId); + } +} diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationAdminController.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationAdminController.java new file mode 100644 index 0000000..9618651 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationAdminController.java @@ -0,0 +1,40 @@ +package group.goforward.battlbuilder.classification.admin; + +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/classification") +public class ClassificationAdminController { + + private final ClassificationReconcileService reconcileService; + + public ClassificationAdminController(ClassificationReconcileService reconcileService) { + this.reconcileService = reconcileService; + } + + @PostMapping("/reconcile") + public ReconcileResponse reconcile(@RequestBody(required = false) ReconcileRequest body, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Integer merchantId, + @RequestParam(required = false) String platform, + @RequestParam(required = false, defaultValue = "false") boolean includeLocked, + @RequestParam(required = false, defaultValue = "true") boolean dryRun) { + + // allow either JSON body or query params. Query params win if provided. + ReconcileRequest req = body != null ? body : new ReconcileRequest(dryRun, 500, null, null, includeLocked); + + int finalLimit = limit != null ? limit : req.limit(); + Integer finalMerchantId = merchantId != null ? merchantId : req.merchantId(); + String finalPlatform = platform != null ? platform : req.platform(); + + ReconcileRequest merged = new ReconcileRequest( + dryRun, + finalLimit, + finalMerchantId, + finalPlatform, + includeLocked + ); + + return reconcileService.reconcile(merged); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java new file mode 100644 index 0000000..a4df457 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ClassificationReconcileService.java @@ -0,0 +1,245 @@ +package group.goforward.battlbuilder.classification.admin; + +import group.goforward.battlbuilder.classification.ClassificationResult; +import group.goforward.battlbuilder.classification.ProductClassifier; +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.PartRoleSource; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repos.ProductRepository; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class ClassificationReconcileService { + + private final ProductRepository productRepository; + private final ProductClassifier productClassifier; + private final ProductOfferRepository productOfferRepository; + + public ClassificationReconcileService(ProductRepository productRepository, + ProductClassifier productClassifier, + ProductOfferRepository productOfferRepository) { + this.productRepository = productRepository; + this.productClassifier = productClassifier; + this.productOfferRepository = productOfferRepository; + + } + + public ReconcileResponse reconcile(ReconcileRequest req) { + + int limit = req.limit(); + boolean dryRun = req.dryRun(); + + // Page in chunks until we hit limit. + final int pageSize = Math.min(250, limit); + int scanned = 0; + int page = 0; + + // Counts by reconcile outcome. + Map counts = new LinkedHashMap<>(); + counts.put("UNCHANGED", 0); + counts.put("WOULD_UPDATE", 0); + counts.put("LOCKED", 0); + counts.put("IGNORED", 0); + counts.put("UNMAPPED", 0); + counts.put("CONFLICT", 0); + counts.put("RULE_MATCHED", 0); + + // Sample rows to inspect quickly in API response. + List samples = new ArrayList<>(); + + // Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1) + Map> mappingMemo = new HashMap<>(); + + + while (scanned < limit) { + var pageable = PageRequest.of(page, pageSize); + + // Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided. + var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable); + if (batch.isEmpty()) break; + + // Avoid N+1: resolve primary merchant for ALL products in this page in one query + List productIds = batch.getContent().stream() + .map(Product::getId) + .filter(Objects::nonNull) + .toList(); + + Map primaryMerchantByProductId = new HashMap<>(); + if (!productIds.isEmpty()) { + productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds) + .forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId())); + } + + for (Product p : batch.getContent()) { + if (scanned >= limit) break; + + // Optional: skip locked products unless includeLocked=true. + boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); + if (!req.includeLocked() && locked) { + counts.compute("LOCKED", (k, v) -> v + 1); + scanned++; + continue; + } + + // Run the classifier (merchant_category_map + other logic inside classifier). + Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId()); + ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo); + + if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) { + counts.compute("RULE_MATCHED", (k, v) -> v + 1); + } + + // Compute diff status (dry-run only right now). + DiffOutcome outcome = diff(p, resolved); + + counts.compute(outcome.status, (k, v) -> v + 1); + scanned++; + + // Keep a small sample set for inspection. + boolean interestingStatus = + outcome.status.equals("WOULD_UPDATE") + || outcome.status.equals("CONFLICT") + || outcome.status.equals("UNMAPPED") + || outcome.status.equals("IGNORED"); + + boolean ruleHit = + resolved != null + && resolved.source() != null + && resolved.source().startsWith("rules_"); + + // Keep a small sample set for inspection. + // Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working. + if (samples.size() < 50 && (interestingStatus || ruleHit)) { + samples.add(toRow(p, resolved, outcome.status, outcome.meta)); + } + } + + if (!batch.hasNext()) break; + page++; + } + + // Dry-run only right now—no writes. + return new ReconcileResponse(dryRun, scanned, counts, samples); + } + + private static class DiffOutcome { + final String status; + final Map meta; + + DiffOutcome(String status, Map meta) { + this.status = status; + this.meta = meta; + } + } + + /** + * Compute reconcile status by comparing: + * - existing product role/source/locks + * - classifier-resolved role/source/confidence/reason + */ + private DiffOutcome diff(Product p, ClassificationResult resolved) { + + String existingRole = trimToNull(p.getPartRole()); + String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole()); + + // Respect locks (never propose changes). + boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked()); + if (locked) { + return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked")); + } + + // Treat known "ignored category" decisions as their own outcome bucket. + if (isIgnored(resolved)) { + return new DiffOutcome("IGNORED", Map.of( + "reason", resolved.reason(), + "source", resolved.source() + )); + } + + // No role resolved = still unmapped (needs merchant_category_map or rule-based inference later). + if (resolvedRole == null) { + return new DiffOutcome("UNMAPPED", Map.of( + "reason", resolved == null ? null : resolved.reason(), + "source", resolved == null ? null : resolved.source(), + "confidence", resolved == null ? null : resolved.confidence() + )); + } + + // Existing role missing but we resolved one => would update. + if (existingRole == null) { + return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole)); + } + + // Same role => unchanged. + if (existingRole.equalsIgnoreCase(resolvedRole)) { + return new DiffOutcome("UNCHANGED", Map.of()); + } + + // If existing role came from an override, flag as conflict (don't auto-clobber). + PartRoleSource existingSource = p.getPartRoleSource(); + if (existingSource == PartRoleSource.OVERRIDE) { + return new DiffOutcome( + "CONFLICT", + Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE") + ); + } + + // Otherwise, it’s a normal drift that we would update in apply-mode. + return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole)); + } + + /** + * Detect the "non-classifying category ignored" decisions from the classifier. + * Right now we key off the reason text prefix (fast + simple). + * Later we can formalize this via a dedicated source or meta flag. + */ + private boolean isIgnored(ClassificationResult r) { + return r != null && "ignored_category".equalsIgnoreCase(r.source()); + } + + private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map meta) { + + // Extract the merchant used by classifier (if provided in meta). + Integer resolvedMerchantId = null; + if (resolved != null && resolved.meta() != null) { + Object v = resolved.meta().get("resolvedMerchantId"); + if (v instanceof Integer i) resolvedMerchantId = i; + else if (v instanceof Number n) resolvedMerchantId = n.intValue(); + else if (v instanceof String s) { + try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {} + } + } + + return new ReconcileDiffRow( + p.getId(), + p.getName(), + p.getPlatform(), + p.getRawCategoryKey(), + + resolvedMerchantId, + + p.getPartRole(), + p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(), + p.getPartRoleLocked(), + p.getPlatformLocked(), + + resolved == null ? null : resolved.partRole(), + resolved == null ? null : resolved.source(), + resolved == null ? 0.0 : resolved.confidence(), + resolved == null ? null : resolved.reason(), + + status, + meta == null ? Map.of() : meta + ); + } + + private static String trimToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileDiffRow.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileDiffRow.java new file mode 100644 index 0000000..b1283c4 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileDiffRow.java @@ -0,0 +1,25 @@ +package group.goforward.battlbuilder.classification.admin; + +import java.util.Map; + +public record ReconcileDiffRow( + Integer productId, + String name, + String platform, + String rawCategoryKey, + + Integer resolvedMerchantId, + + String existingPartRole, + String existingSource, + Boolean partRoleLocked, + Boolean platformLocked, + + String resolvedPartRole, + String resolvedSource, + double resolvedConfidence, + String resolvedReason, + + String status, // UNCHANGED | WOULD_UPDATE | LOCKED | UNMAPPED | CONFLICT + Map meta +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileRequest.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileRequest.java new file mode 100644 index 0000000..b5f0c1b --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileRequest.java @@ -0,0 +1,14 @@ +package group.goforward.battlbuilder.classification.admin; + +public record ReconcileRequest( + boolean dryRun, + int limit, + Integer merchantId, + String platform, + boolean includeLocked +) { + public ReconcileRequest { + if (limit <= 0) limit = 500; + if (limit > 5000) limit = 5000; // safety cap + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileResponse.java b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileResponse.java new file mode 100644 index 0000000..64bd2f0 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/admin/ReconcileResponse.java @@ -0,0 +1,11 @@ +package group.goforward.battlbuilder.classification.admin; + +import java.util.List; +import java.util.Map; + +public record ReconcileResponse( + boolean dryRun, + int scanned, + Map counts, + List samples +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java b/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java new file mode 100644 index 0000000..dcdbe08 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/classification/impl/ProductClassifierImpl.java @@ -0,0 +1,474 @@ +package group.goforward.battlbuilder.classification.impl; + +import group.goforward.battlbuilder.classification.ClassificationResult; +import group.goforward.battlbuilder.classification.ProductClassifier; +import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.HashMap; + +@Component +public class ProductClassifierImpl implements ProductClassifier { + + /** + * Bump this whenever you change classification logic. + * Useful for debugging and for "reconcile" runs. + */ + private static final String VERSION = "2025.12.29"; + + /** + * Non-classifying "categories" some merchants use as merchandising labels. + * These are orthogonal to part role (condition/marketing), so we ignore them + * to avoid polluting merchant_category_map. + */ + // IMPORTANT: + // These tokens represent NON-SEMANTIC merchant categories. + // They should NEVER be mapped to part roles. + // If you're tempted to add them to merchant_category_map, add them here instead. + private static final Set NON_CLASSIFYING_TOKENS = Set.of( + // marketing / promos + "sale", + "clearance", + "deal", + "special", + "markdown", + "promo", + + // merchandising buckets + "general", + "apparel", + "accessories", + "parts", + "spare parts", + "shop all", + "lineup", + "collection", + + // bundles / kits / sets + "bundle", + "kit", + "set", + "builder set", + + // color / variant groupings + "colors", + "finish", + "variant", + + // caliber / platform / type filters (not part roles) + "bolt action", + "ar15", + "ar-15", + "rifles", + "creedmoor", + "winchester", + "grendel", + "legend", + + // promo shelves + "savings", + + // brand shelves + "magpul", + + // caliber shelves (not part roles) + "5.56", + "5.56 nato", + "223", + ".223", + "223 wylde", + ".223 wylde", + "wylde", + "nato" + ); + + private final MerchantCategoryMapRepository merchantCategoryMapRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository, + ProductOfferRepository productOfferRepository) { + this.merchantCategoryMapRepository = merchantCategoryMapRepository; + this.productOfferRepository = productOfferRepository; + } + + @Override + public ClassificationResult classifyProduct(Product product) { + // Backwards compatible path (existing callers) + return classifyProduct(product, null); + } + + @Override + public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) { + + // ===== Guardrails ===== + if (product == null || product.getId() == null) { + return ClassificationResult.unknown(VERSION, "Missing product or product.id."); + } + + if (isBlank(product.getRawCategoryKey())) { + return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); + } + + // Normalize inputs early so we don't accidentally compare mismatched strings. + String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); + String platform = normalizePlatform(product.getPlatform()); // may be null + + // Resolve merchant-of-record: + // - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1) + // - Fall back to DB lookup for normal runtime usage + Integer merchantId = resolvedMerchantId; + if (merchantId == null) { + merchantId = productOfferRepository + .findPrimaryMerchantIdByFirstSeen(product.getId()) + .orElse(null); + } + + // ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) ===== + if (isNonClassifyingCategory(rawCategory)) { + return new ClassificationResult( + null, + "ignored_category", + "Ignored non-classifying merchant category: " + rawCategory, + VERSION, + 0.0, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform + ) + ); + } + + // If we need merchant mapping or rules, merchantId is required + if (merchantId == null) { + return ClassificationResult.unknown( + VERSION, + "No offers found for product; cannot determine merchant mapping." + ); + } + + // ===== Rule-based split: broad "Gas System" buckets ===== + if (isGasSystemBucket(rawCategory)) { + String name = (product.getName() == null) ? "" : product.getName(); + String n = name.toLowerCase(Locale.ROOT); + + if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) { + return new ClassificationResult( + "gas-block-tube-combo", + "rules_gas_system", + "Gas System bucket: inferred gas-block-tube-combo from name.", + VERSION, + 0.92, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo") + ); + } + + if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) { + return new ClassificationResult( + "gas-tube-roll-pin", + "rules_gas_system", + "Gas System bucket: inferred gas-tube-roll-pin from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin") + ); + } + + if (containsAny(n, "gas tube", "gastube")) { + return new ClassificationResult( + "gas-tube", + "rules_gas_system", + "Gas System bucket: inferred gas-tube from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube") + ); + } + + if (containsAny(n, "gas block", "gasblock")) { + return new ClassificationResult( + "gas-block", + "rules_gas_system", + "Gas System bucket: inferred gas-block from name.", + VERSION, + 0.90, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block") + ); + } + + return new ClassificationResult( + null, + "rules_gas_system", + "Gas System bucket: no confident keyword match (leave unmapped).", + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none") + ); + } + + // ===== Primary classification: merchant_category_map lookup ===== + Optional best = + merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform); + + if (best.isPresent()) { + MerchantCategoryMap map = best.get(); + + String role = trimToNull(map.getCanonicalPartRole()); + if (role == null) { + return new ClassificationResult( + null, + "merchant_mapping", + "Mapping found but canonicalPartRole is empty (needs admin mapping).", + VERSION, + 0.20, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + double confidence = + platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : + "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : + map.getPlatform() == null ? 0.90 : 0.90; + + return new ClassificationResult( + role, + "merchant_mapping", + "Mapped via merchant_category_map (canonical_part_role).", + VERSION, + confidence, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + return new ClassificationResult( + null, + "merchant_mapping", + "No enabled mapping found for resolved merchant/rawCategory/platform.", + VERSION, + 0.0, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform + ) + ); + } + + @Override + public ClassificationResult classifyProduct( + Product product, + Integer resolvedMerchantId, + Map> mappingMemo + ) { + // Safety: if caller passes null, fall back cleanly + if (mappingMemo == null) { + mappingMemo = new HashMap<>(); + } + + // ===== Guardrails ===== + if (product == null || product.getId() == null) { + return ClassificationResult.unknown(VERSION, "Missing product or product.id."); + } + + if (isBlank(product.getRawCategoryKey())) { + return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product."); + } + + String rawCategory = normalizeRawCategory(product.getRawCategoryKey()); + String platform = normalizePlatform(product.getPlatform()); + + Integer merchantId = resolvedMerchantId; + if (merchantId == null) { + merchantId = productOfferRepository + .findPrimaryMerchantIdByFirstSeen(product.getId()) + .orElse(null); + } + + // Ignore merchandising/filters + if (isNonClassifyingCategory(rawCategory)) { + return new ClassificationResult( + null, + "ignored_category", + "Ignored non-classifying merchant category: " + rawCategory, + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) + ); + } + + if (merchantId == null) { + return ClassificationResult.unknown( + VERSION, + "No offers found for product; cannot determine merchant mapping." + ); + } + + // ===== Gas System rules (unchanged) ===== + if (isGasSystemBucket(rawCategory)) { + // reuse your existing logic by calling the 2-arg version + return classifyProduct(product, merchantId); + } + + // ===== Memoized merchant_category_map lookup ===== + // Key is normalized to maximize cache hits across equivalent strings. + String memoKey = + merchantId + "|" + + (platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" + + rawCategory.toLowerCase(Locale.ROOT).trim(); + + final Integer mid = merchantId; + final String rc = rawCategory; + final String pl = platform; + + Optional best = mappingMemo.computeIfAbsent( + memoKey, + k -> merchantCategoryMapRepository.findBest(mid, rc, pl) + ); + + if (best.isPresent()) { + MerchantCategoryMap map = best.get(); + + String role = trimToNull(map.getCanonicalPartRole()); + if (role == null) { + return new ClassificationResult( + null, + "merchant_mapping", + "Mapping found but canonicalPartRole is empty (needs admin mapping).", + VERSION, + 0.20, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + double confidence = + platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 : + "ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 : + map.getPlatform() == null ? 0.90 : 0.90; + + return new ClassificationResult( + role, + "merchant_mapping", + "Mapped via merchant_category_map (canonical_part_role).", + VERSION, + confidence, + meta( + "resolvedMerchantId", merchantId, + "rawCategory", rawCategory, + "platform", platform, + "mapId", map.getId(), + "mapPlatform", map.getPlatform() + ) + ); + } + + return new ClassificationResult( + null, + "merchant_mapping", + "No enabled mapping found for resolved merchant/rawCategory/platform.", + VERSION, + 0.0, + meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform) + ); + } + + + /** + * Returns true if this raw category is a merchandising/condition label rather than a taxonomy path. + */ + private boolean isNonClassifyingCategory(String rawCategory) { + if (rawCategory == null) return false; + + String v = rawCategory.toLowerCase(Locale.ROOT).trim(); + + // If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label. + // Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers" + if (v.contains(">")) return false; + + // For flat categories, apply token matching. + return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains); + } + + /** + * Normalize merchant-provided category strings. + * Keep it light: trim + collapse whitespace. + * (If needed later: unify case or canonicalize separators.) + */ + private String normalizeRawCategory(String raw) { + if (raw == null) return null; + return raw.trim().replaceAll("\\s+", " "); + } + + /** + * Normalize platform values. We keep it consistent with expected inputs like "AR-15". + * Uppercasing is okay because "AR-15" remains "AR-15". + */ + private String normalizePlatform(String p) { + if (p == null) return null; + String t = p.trim(); + return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT); + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private String trimToNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private boolean isGasSystemBucket(String rawCategory) { + if (rawCategory == null) return false; + String v = rawCategory.toLowerCase(Locale.ROOT); + return v.contains("gas system"); + } + + private boolean containsAny(String haystack, String... needles) { + if (haystack == null) return false; + for (String n : needles) { + if (n != null && !n.isBlank() && haystack.contains(n)) return true; + } + return false; + } + + /** + * Safe metadata builder. + * IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries. + */ + private static Map meta(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + String k = (String) kv[i]; + Object v = kv[i + 1]; + if (k != null && v != null) m.put(k, v); + } + return m; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/CanonicalCategory.java b/src/main/java/group/goforward/battlbuilder/model/CanonicalCategory.java new file mode 100644 index 0000000..110a7a8 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/model/CanonicalCategory.java @@ -0,0 +1,79 @@ +package group.goforward.battlbuilder.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +@Entity +@Table(name = "canonical_categories") +public class CanonicalCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Integer id; + + @NotNull + @Column(name = "name", nullable = false, length = 255) + private String name; + + @NotNull + @Column(name = "slug", nullable = false, length = 255) + private String slug; + + /** + * Self-referencing parent category. + * Example: "Upper Parts" -> parent "AR-15" (or whatever your top-level is). + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private CanonicalCategory parent; + + @NotNull + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @NotNull + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @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; + } + + @PreUpdate + public void preUpdate() { + updatedAt = OffsetDateTime.now(ZoneOffset.UTC); + } + + // --- getters/setters --- + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public CanonicalCategory getParent() { return parent; } + public void setParent(CanonicalCategory parent) { this.parent = parent; } + + 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 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/MerchantCategoryMap.java b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java index 95fdbce..8e0b3e4 100644 --- a/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java +++ b/src/main/java/group/goforward/battlbuilder/model/MerchantCategoryMap.java @@ -27,26 +27,21 @@ public class MerchantCategoryMap { @Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE) private String rawCategory; - /** - * Canonical role you want to classify to. - * Prefer this over partRole if present (legacy). - */ @Column(name = "canonical_part_role", length = 255) private String canonicalPartRole; - /** - * 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; + // ===== Catalog Category (FK) - Option B ===== + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "canonical_category_id") + private CanonicalCategory canonicalCategory; + + // ===== LEGACY — safe to remove after full migration + @Column(name = "canonical_category", length = 255) + private String canonicalCategoryText; + @NotNull @Column(name = "enabled", nullable = false) private Boolean enabled = true; @@ -76,6 +71,7 @@ public class MerchantCategoryMap { if (enabled == null) enabled = true; } + // --- getters/setters --- public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } @@ -91,6 +87,12 @@ public class MerchantCategoryMap { public String getPlatform() { return platform; } public void setPlatform(String platform) { this.platform = platform; } + public CanonicalCategory getCanonicalCategory() { return canonicalCategory; } + public void setCanonicalCategory(CanonicalCategory canonicalCategory) { this.canonicalCategory = canonicalCategory; } + + public String getCanonicalCategoryText() { return canonicalCategoryText; } + public void setCanonicalCategoryText(String canonicalCategoryText) { this.canonicalCategoryText = canonicalCategoryText; } + public Boolean getEnabled() { return enabled; } public void setEnabled(Boolean enabled) { this.enabled = enabled; } diff --git a/src/main/java/group/goforward/battlbuilder/repos/CanonicalCategoryRepository.java b/src/main/java/group/goforward/battlbuilder/repos/CanonicalCategoryRepository.java new file mode 100644 index 0000000..7f8e7b8 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/repos/CanonicalCategoryRepository.java @@ -0,0 +1,18 @@ +package group.goforward.battlbuilder.repos; + +import group.goforward.battlbuilder.model.CanonicalCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface CanonicalCategoryRepository extends JpaRepository { + + @Query(""" + select c + from CanonicalCategory c + where c.deletedAt is null + order by c.name asc + """) + List findAllActive(); +} \ 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 63dd5c4..734ee54 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/MerchantCategoryMapRepository.java @@ -12,6 +12,41 @@ import java.util.Optional; @Repository public interface MerchantCategoryMapRepository extends JpaRepository { + // Pull candidates ordered by platform specificity: exact match first, then ANY/null. + @Query(""" + select m + from MerchantCategoryMap m + where m.merchant.id = :merchantId + and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true + and m.deletedAt is null + and (m.platform is null or m.platform = 'ANY' or m.platform = :platform) + order by + case + when m.platform = :platform then 0 + when m.platform = 'ANY' then 1 + when m.platform is null then 2 + else 3 + end, + m.updatedAt desc + """) + List findCandidates( + @Param("merchantId") Integer merchantId, + @Param("rawCategory") String rawCategory, + @Param("platform") String platform + ); + + default Optional findBest(Integer merchantId, String rawCategory, String platform) { + List candidates = findCandidates(merchantId, rawCategory, platform); + return candidates.stream().findFirst(); + } + + // Optional helper if you want a quick "latest mapping regardless of platform" + Optional findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( + Integer merchantId, + String rawCategory + ); + + // Optional: if you still want a role-only lookup list for debugging @Query(""" select mcm.canonicalPartRole from MerchantCategoryMap mcm @@ -25,13 +60,4 @@ public interface MerchantCategoryMapRepository extends JpaRepository findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc( - Integer merchantId, - String rawCategory - ); - - } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java index d90e870..5049100 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductOfferRepository.java @@ -38,4 +38,40 @@ public interface ProductOfferRepository extends JpaRepository findByProductIdWithMerchant(@Param("productId") Integer productId); -} \ No newline at end of file + + + // Pick merchant with newest lastSeenAt (or adjust to firstSeenAt if you prefer) + @Query(value = """ + select po.merchant_id + from product_offers po + where po.product_id = :productId + order by po.first_seen_at asc nulls last, po.id asc + limit 1 + """, nativeQuery = true) + Optional findPrimaryMerchantIdByFirstSeen(@Param("productId") Integer productId); + + public interface ProductPrimaryMerchantRow { + Integer getProductId(); + Integer getMerchantId(); + } + + @Query(value = """ + select t.product_id as productId, t.merchant_id as merchantId + from ( + select + po.product_id, + po.merchant_id, + row_number() over ( + partition by po.product_id + order by po.first_seen_at asc nulls last, po.id asc + ) as rn + from product_offers po + where po.product_id in (:productIds) + ) t + where t.rn = 1 + """, nativeQuery = true) + List findPrimaryMerchantsByFirstSeenForProductIds( + @Param("productIds") List productIds + ); +} + diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index f2139ee..aff9e87 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +17,123 @@ import java.util.Map; public interface ProductRepository extends JpaRepository { + + /** + * Catalog mapping UI: + * Returns raw categories for a given merchant + optional platform/q filter, + * along with current merchant_category_map state and canonical category name. + * + * Row shape MUST match MappingAdminService mapping: + * r[0] merchantId (Integer) + * r[1] merchantName (String) + * r[2] platform (String) + * r[3] rawCategoryKey (String) + * r[4] productCount (Number) + * r[5] mcmId (Number -> Long) + * r[6] enabled (Boolean) + * r[7] canonicalPartRole (String) + * r[8] canonicalCategoryId (Number -> Long) + * r[9] canonicalCategoryName (String) + */ + @Query(value = """ + with primary_offer as ( + select * + from ( + select + po.product_id, + po.merchant_id, + row_number() over ( + partition by po.product_id + order by po.first_seen_at asc nulls last, po.id asc + ) as rn + from product_offers po + where po.merchant_id = :merchantId + ) x + where x.rn = 1 + ), + buckets as ( + select + po.merchant_id, + p.platform, + p.raw_category_key, + count(*) as product_count + from products p + join primary_offer po on po.product_id = p.id + where p.deleted_at is null + and p.raw_category_key is not null + and (:platform is null or p.platform = :platform) + and (:q is null or lower(p.raw_category_key) like concat('%', lower(:q), '%')) + group by po.merchant_id, p.platform, p.raw_category_key + ) + select + b.merchant_id as merchant_id, + m.name as merchant_name, + b.platform as platform, + b.raw_category_key as raw_category_key, + b.product_count as product_count, + + mcm.id as mcm_id, + coalesce(mcm.enabled, false) as enabled, + mcm.canonical_part_role as canonical_part_role, + + mcm.canonical_category_id as canonical_category_id, + cc.name as canonical_category_name + + from buckets b + join merchants m on m.id = b.merchant_id + left join merchant_category_map mcm + on mcm.merchant_id = b.merchant_id + and mcm.deleted_at is null + and mcm.enabled = true + and lower(mcm.raw_category) = lower(b.raw_category_key) + -- platform-aware mapping preference: + and (mcm.platform is null or mcm.platform = 'ANY' or mcm.platform = b.platform) + + left join canonical_categories cc + on cc.id = mcm.canonical_category_id + and cc.deleted_at is null + + order by b.product_count desc, b.raw_category_key asc + limit :limit + """, nativeQuery = true) + List findRawCategoryMappingRows( + @Param("merchantId") Integer merchantId, + @Param("platform") String platform, + @Param("q") String q, + @Param("limit") int limit + ); + + @Modifying + @Query(value = """ +with primary_offer as ( + select * + from ( + select + po.product_id, + po.merchant_id, + row_number() over ( + partition by po.product_id + order by po.first_seen_at asc nulls last, po.id asc + ) as rn + from product_offers po + ) x + where x.rn = 1 +) +update products p +set canonical_category_id = :canonicalCategoryId, + updated_at = now() +from primary_offer po +where po.product_id = p.id + and po.merchant_id = :merchantId + and p.deleted_at is null + and p.raw_category_key = :rawCategoryKey +""", nativeQuery = true) + int applyCanonicalCategoryByPrimaryMerchantAndRawCategory( + @Param("merchantId") Integer merchantId, + @Param("rawCategoryKey") String rawCategoryKey, + @Param("canonicalCategoryId") Integer canonicalCategoryId + ); + // ------------------------------------------------- // Used by MerchantFeedImportServiceImpl // ------------------------------------------------- @@ -260,6 +378,22 @@ ORDER BY productCount DESC """) Page findNeedingBattlImageUrlMigration(Pageable pageable); + + @Query(""" + select distinct p + from Product p + join ProductOffer po on po.product.id = p.id + where p.deletedAt is null + and (:platform is null or p.platform = :platform) + and (:merchantId is null or po.merchant.id = :merchantId) + order by p.id asc + """) + Page pageActiveProductsByOfferMerchant( + @Param("merchantId") Integer merchantId, + @Param("platform") String platform, + Pageable pageable + ); + // ------------------------------------------------- // Enrichment: find products missing caliber and NOT already queued // ------------------------------------------------- diff --git a/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java b/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java index 1f65639..f0fc5fc 100644 --- a/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java +++ b/src/main/java/group/goforward/battlbuilder/services/MappingAdminService.java @@ -3,14 +3,20 @@ package group.goforward.battlbuilder.services; import group.goforward.battlbuilder.model.ImportStatus; import group.goforward.battlbuilder.model.Merchant; import group.goforward.battlbuilder.model.MerchantCategoryMap; +import group.goforward.battlbuilder.model.CanonicalCategory; +import group.goforward.battlbuilder.repos.CanonicalCategoryRepository; import group.goforward.battlbuilder.repos.MerchantCategoryMapRepository; import group.goforward.battlbuilder.repos.MerchantRepository; import group.goforward.battlbuilder.repos.ProductRepository; +import group.goforward.battlbuilder.web.dto.MappingOptionsDto; import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; +import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; 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 MappingAdminService { @@ -18,20 +24,27 @@ public class MappingAdminService { private final ProductRepository productRepository; private final MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantRepository merchantRepository; + private final CanonicalCategoryRepository canonicalCategoryRepository; private final ReclassificationService reclassificationService; public MappingAdminService( ProductRepository productRepository, MerchantCategoryMapRepository merchantCategoryMapRepository, MerchantRepository merchantRepository, + CanonicalCategoryRepository canonicalCategoryRepository, ReclassificationService reclassificationService ) { this.productRepository = productRepository; this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantRepository = merchantRepository; + this.canonicalCategoryRepository = canonicalCategoryRepository; this.reclassificationService = reclassificationService; } + // ========================= + // 1) EXISTING: Role buckets + // ========================= + @Transactional(readOnly = true) public List listPendingBuckets() { List rows = @@ -55,10 +68,8 @@ public class MappingAdminService { } /** - * 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 + * Part Role mapping: + * Writes merchant_category_map.canonical_part_role and applies to products. */ @Transactional public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { @@ -70,12 +81,13 @@ public class MappingAdminService { Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + // NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below. MerchantCategoryMap mapping = new MerchantCategoryMap(); mapping.setMerchant(merchant); - mapping.setRawCategory(rawCategoryKey); + mapping.setRawCategory(rawCategoryKey.trim()); mapping.setEnabled(true); -// SOURCE OF TRUTH + // SOURCE OF TRUTH (builder slot mapping) mapping.setCanonicalPartRole(mappedPartRole.trim()); merchantCategoryMapRepository.save(mapping); @@ -83,24 +95,134 @@ public class MappingAdminService { 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"); + // ========================================== + // 2) NEW: Options endpoint for Catalog UI + // ========================================== + + @Transactional(readOnly = true) + public MappingOptionsDto getOptions() { + var merchants = merchantRepository.findAll().stream() + .map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName())) + .toList(); + + var categories = canonicalCategoryRepository.findAllActive().stream() + .map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto( + c.getId(), + c.getName(), + c.getSlug() + )) + .toList(); + + return new MappingOptionsDto(merchants, categories); + } + + // ===================================================== + // 3) NEW: Raw categories list for Catalog mapping table + // ===================================================== + + @Transactional(readOnly = true) + public List listRawCategories(Integer merchantId, String platform, String q, Integer limit) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + + String plat = normalizePlatform(platform); + String query = (q == null || q.isBlank()) ? null : q.trim(); + int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000); + + List rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim); + + return rows.stream().map(r -> new RawCategoryMappingRowDto( + (Integer) r[0], // merchantId + (String) r[1], // merchantName + (String) r[2], // platform + (String) r[3], // rawCategoryKey + ((Number) r[4]).longValue(), // productCount + (r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId + (Boolean) r[6], // enabled + (String) r[7], // canonicalPartRole + + // IMPORTANT: canonicalCategoryId should be Integer, not Long. + (r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer) + (String) r[9] // canonicalCategoryName + )).toList(); + } + + // ========================================================== + // 4) NEW: Upsert catalog mapping + // ========================================================== + + public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {} + + @Transactional + public UpsertCatalogMappingResult upsertCatalogMapping( + Integer merchantId, + String platform, + String rawCategory, + Boolean enabled, + Integer canonicalCategoryId // <-- Integer (NOT Long) + ) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required"); + + String plat = normalizePlatform(platform); + String raw = rawCategory.trim(); + boolean en = (enabled == null) ? true : enabled; + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + CanonicalCategory cat = null; + if (canonicalCategoryId != null) { + cat = canonicalCategoryRepository.findById(canonicalCategoryId) + .orElseThrow(() -> new IllegalArgumentException( + "CanonicalCategory not found: " + canonicalCategoryId + )); } + + // Find mapping row (platform-specific first; then ANY/null via your findBest ordering) + Optional existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat); + MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new); + + // Always ensure required fields are set + mcm.setMerchant(merchant); + mcm.setRawCategory(raw); + mcm.setPlatform(plat); + mcm.setEnabled(en); + + // Catalog mapping fields (FK + legacy mirror) + mcm.setCanonicalCategory(cat); // FK (preferred) + mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror + + // IMPORTANT: DO NOT clobber canonicalPartRole here + + merchantCategoryMapRepository.save(mcm); + + // Push category FK to products + int updated = reclassificationService.applyCatalogCategoryMappingToProducts( + merchantId, + raw, + canonicalCategoryId // can be null to clear + ); + + return new UpsertCatalogMappingResult(mcm.getId(), updated); + } + + // ----------------- + // Helpers + // ----------------- + + private String normalizePlatform(String p) { + if (p == null) return null; + String t = p.trim(); + if (t.isEmpty()) return null; + return t.toUpperCase(Locale.ROOT); } } \ 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 85b2cd1..aab6a2a 100644 --- a/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java +++ b/src/main/java/group/goforward/battlbuilder/services/ReclassificationService.java @@ -2,5 +2,10 @@ package group.goforward.battlbuilder.services; public interface ReclassificationService { int reclassifyPendingForMerchant(Integer merchantId); + + // Existing: apply canonical_part_role mapping to products int applyMappingToProducts(Integer merchantId, String rawCategoryKey); + + // NEW: apply canonical_category_id mapping to products + int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId); } \ 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 bf3f5b1..3d937fe 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/ReclassificationServiceImpl.java @@ -22,6 +22,7 @@ public class ReclassificationServiceImpl implements ReclassificationService { private final ProductRepository productRepository; private final MerchantCategoryMappingService merchantCategoryMappingService; + // ✅ Keep ONE constructor. Spring will inject both deps. public ReclassificationServiceImpl( ProductRepository productRepository, MerchantCategoryMappingService merchantCategoryMappingService @@ -30,6 +31,28 @@ public class ReclassificationServiceImpl implements ReclassificationService { this.merchantCategoryMappingService = merchantCategoryMappingService; } + // ============================ + // Catalog category FK backfill + // ============================ + + @Override + @Transactional + public int applyCatalogCategoryMappingToProducts( + Integer merchantId, + String rawCategoryKey, + Integer canonicalCategoryId + ) { + if (merchantId == null) throw new IllegalArgumentException("merchantId is required"); + if (rawCategoryKey == null || rawCategoryKey.isBlank()) + throw new IllegalArgumentException("rawCategoryKey is required"); + + return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory( + merchantId, + rawCategoryKey.trim(), + canonicalCategoryId + ); + } + /** * Optional helper: bulk reclassify only PENDING_MAPPING for a merchant, * using ONLY merchant_category_map (no rules, no inference). @@ -134,6 +157,10 @@ public class ReclassificationServiceImpl implements ReclassificationService { return updated; } + // ----------------- + // Helpers + // ----------------- + private String normalizePlatformOrNull(String platform) { if (platform == null) return null; String t = platform.trim(); 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 cc0ac36..e617f93 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminMappingController.java @@ -2,6 +2,8 @@ package group.goforward.battlbuilder.web.admin; import group.goforward.battlbuilder.services.MappingAdminService; import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto; +import group.goforward.battlbuilder.web.dto.MappingOptionsDto; +import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -63,4 +65,56 @@ public class AdminMappingController { "updatedProducts", updated )); } + + /** + * Options for the UI (merchant dropdown + canonical categories dropdown) + */ + @GetMapping("/options") + public MappingOptionsDto options() { + return mappingAdminService.getOptions(); + } + + /** + * Catalog mapping rows: raw categories for a merchant + platform, plus current mapping state + */ + @GetMapping("/raw-categories") + public List rawCategories( + @RequestParam Integer merchantId, + @RequestParam(required = false) String platform, + @RequestParam(required = false) String q, + @RequestParam(required = false, defaultValue = "500") Integer limit + ) { + return mappingAdminService.listRawCategories(merchantId, platform, q, limit); + } + + public record UpsertCatalogMappingRequest( + Integer merchantId, + String platform, // nullable okay + String rawCategory, // maps to merchant_category_map.raw_category + Boolean enabled, // nullable => default true in service + Integer canonicalCategoryId // nullable allowed (unmapped) + ) {} + + /** + * Upsert ONLY the catalog category mapping in merchant_category_map. + * IMPORTANT: does NOT touch canonical_part_role. + */ + @PostMapping("/upsert") + public ResponseEntity> upsertCatalogMapping( + @RequestBody UpsertCatalogMappingRequest request + ) { + var result = mappingAdminService.upsertCatalogMapping( + request.merchantId(), + request.platform(), + request.rawCategory(), + request.enabled(), + request.canonicalCategoryId() + ); + + return ResponseEntity.ok(Map.of( + "ok", true, + "merchantCategoryMapId", result.merchantCategoryMapId(), + "updatedProducts", result.updatedProducts() + )); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/MappingOptionsDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/MappingOptionsDto.java new file mode 100644 index 0000000..8dfc8ca --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/MappingOptionsDto.java @@ -0,0 +1,11 @@ +package group.goforward.battlbuilder.web.dto; + +import java.util.List; + +public record MappingOptionsDto( + List merchants, + List canonicalCategories +) { + public record MerchantOptionDto(Integer id, String name) {} + public record CanonicalCategoryOptionDto(Integer id, String name, String slug) {} +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/RawCategoryMappingRowDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/RawCategoryMappingRowDto.java new file mode 100644 index 0000000..d7abf43 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/RawCategoryMappingRowDto.java @@ -0,0 +1,17 @@ +package group.goforward.battlbuilder.web.dto; + +public record RawCategoryMappingRowDto( + Integer merchantId, + String merchantName, + String platform, // whatever platform filter was used (or null) + String rawCategoryKey, + Long productCount, + + Long mcmId, + Boolean enabled, + + // current mapping values from merchant_category_map + String canonicalPartRole, + Integer canonicalCategoryId, + String canonicalCategoryName +) {} \ No newline at end of file