mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
shit ton. reworked parts role and canonical categories.
This commit is contained in:
@@ -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<String, Object> meta
|
||||||
|
) {
|
||||||
|
public static ClassificationResult unknown(String version, String reason) {
|
||||||
|
return new ClassificationResult(null, "unknown", reason, version, 0.0, Map.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Optional<MerchantCategoryMap>> mappingMemo
|
||||||
|
) {
|
||||||
|
// Default fallback so existing implementations compile
|
||||||
|
return classifyProduct(product, resolvedMerchantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Integer> 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<ReconcileDiffRow> samples = new ArrayList<>();
|
||||||
|
|
||||||
|
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
|
||||||
|
Map<String, Optional<MerchantCategoryMap>> 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<Integer> productIds = batch.getContent().stream()
|
||||||
|
.map(Product::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<Integer, Integer> 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<String, Object> meta;
|
||||||
|
|
||||||
|
DiffOutcome(String status, Map<String, Object> 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<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> meta
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Integer> counts,
|
||||||
|
List<ReconcileDiffRow> samples
|
||||||
|
) {}
|
||||||
@@ -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<String> 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<MerchantCategoryMap> 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<String, Optional<MerchantCategoryMap>> 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<MerchantCategoryMap> 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<String, Object> meta(Object... kv) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -27,26 +27,21 @@ public class MerchantCategoryMap {
|
|||||||
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "raw_category", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String rawCategory;
|
private String rawCategory;
|
||||||
|
|
||||||
/**
|
|
||||||
* Canonical role you want to classify to.
|
|
||||||
* Prefer this over partRole if present (legacy).
|
|
||||||
*/
|
|
||||||
@Column(name = "canonical_part_role", length = 255)
|
@Column(name = "canonical_part_role", length = 255)
|
||||||
private String canonicalPartRole;
|
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)
|
@Column(name = "platform", length = 64)
|
||||||
private String platform;
|
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
|
@NotNull
|
||||||
@Column(name = "enabled", nullable = false)
|
@Column(name = "enabled", nullable = false)
|
||||||
private Boolean enabled = true;
|
private Boolean enabled = true;
|
||||||
@@ -76,6 +71,7 @@ public class MerchantCategoryMap {
|
|||||||
if (enabled == null) enabled = true;
|
if (enabled == null) enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- getters/setters ---
|
||||||
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; }
|
||||||
|
|
||||||
@@ -91,6 +87,12 @@ public class MerchantCategoryMap {
|
|||||||
public String getPlatform() { return platform; }
|
public String getPlatform() { return platform; }
|
||||||
public void setPlatform(String platform) { this.platform = 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 Boolean getEnabled() { return enabled; }
|
||||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
|||||||
@@ -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<CanonicalCategory, Integer> {
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select c
|
||||||
|
from CanonicalCategory c
|
||||||
|
where c.deletedAt is null
|
||||||
|
order by c.name asc
|
||||||
|
""")
|
||||||
|
List<CanonicalCategory> findAllActive();
|
||||||
|
}
|
||||||
@@ -12,6 +12,41 @@ import java.util.Optional;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
|
||||||
|
|
||||||
|
// 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<MerchantCategoryMap> findCandidates(
|
||||||
|
@Param("merchantId") Integer merchantId,
|
||||||
|
@Param("rawCategory") String rawCategory,
|
||||||
|
@Param("platform") String platform
|
||||||
|
);
|
||||||
|
|
||||||
|
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
|
||||||
|
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
|
||||||
|
return candidates.stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional helper if you want a quick "latest mapping regardless of platform"
|
||||||
|
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategory
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: if you still want a role-only lookup list for debugging
|
||||||
@Query("""
|
@Query("""
|
||||||
select mcm.canonicalPartRole
|
select mcm.canonicalPartRole
|
||||||
from MerchantCategoryMap mcm
|
from MerchantCategoryMap mcm
|
||||||
@@ -25,13 +60,4 @@ public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCat
|
|||||||
@Param("merchantId") Integer merchantId,
|
@Param("merchantId") Integer merchantId,
|
||||||
@Param("rawCategory") String rawCategory
|
@Param("rawCategory") String rawCategory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Optional convenience method (you can keep, but service logic will handle platform preference)
|
|
||||||
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
|
|
||||||
Integer merchantId,
|
|
||||||
String rawCategory
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -38,4 +38,40 @@ public interface ProductOfferRepository extends JpaRepository<ProductOffer, Inte
|
|||||||
where po.product.id = :productId
|
where po.product.id = :productId
|
||||||
""")
|
""")
|
||||||
List<ProductOffer> findByProductIdWithMerchant(@Param("productId") Integer productId);
|
List<ProductOffer> findByProductIdWithMerchant(@Param("productId") Integer productId);
|
||||||
|
|
||||||
|
|
||||||
|
// 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<Integer> 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<ProductPrimaryMerchantRow> findPrimaryMerchantsByFirstSeenForProductIds(
|
||||||
|
@Param("productIds") List<Integer> productIds
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
@@ -16,6 +17,123 @@ import java.util.Map;
|
|||||||
|
|
||||||
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Object[]> 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
|
// Used by MerchantFeedImportServiceImpl
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
@@ -260,6 +378,22 @@ ORDER BY productCount DESC
|
|||||||
""")
|
""")
|
||||||
Page<Product> findNeedingBattlImageUrlMigration(Pageable pageable);
|
Page<Product> 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<Product> pageActiveProductsByOfferMerchant(
|
||||||
|
@Param("merchantId") Integer merchantId,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Enrichment: find products missing caliber and NOT already queued
|
// Enrichment: find products missing caliber and NOT already queued
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ package group.goforward.battlbuilder.services;
|
|||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
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.MerchantCategoryMapRepository;
|
||||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
import group.goforward.battlbuilder.repos.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
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.PendingMappingBucketDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MappingAdminService {
|
public class MappingAdminService {
|
||||||
@@ -18,20 +24,27 @@ public class MappingAdminService {
|
|||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final CanonicalCategoryRepository canonicalCategoryRepository;
|
||||||
private final ReclassificationService reclassificationService;
|
private final ReclassificationService reclassificationService;
|
||||||
|
|
||||||
public MappingAdminService(
|
public MappingAdminService(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
MerchantCategoryMapRepository merchantCategoryMapRepository,
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
|
CanonicalCategoryRepository canonicalCategoryRepository,
|
||||||
ReclassificationService reclassificationService
|
ReclassificationService reclassificationService
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.canonicalCategoryRepository = canonicalCategoryRepository;
|
||||||
this.reclassificationService = reclassificationService;
|
this.reclassificationService = reclassificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 1) EXISTING: Role buckets
|
||||||
|
// =========================
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<PendingMappingBucketDto> listPendingBuckets() {
|
public List<PendingMappingBucketDto> listPendingBuckets() {
|
||||||
List<Object[]> rows =
|
List<Object[]> rows =
|
||||||
@@ -55,10 +68,8 @@ public class MappingAdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates/updates the mapping row, then immediately applies it to products so the UI updates
|
* Part Role mapping:
|
||||||
* without requiring a re-import.
|
* Writes merchant_category_map.canonical_part_role and applies to products.
|
||||||
*
|
|
||||||
* @return number of products updated
|
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
||||||
@@ -70,12 +81,13 @@ public class MappingAdminService {
|
|||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + 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();
|
MerchantCategoryMap mapping = new MerchantCategoryMap();
|
||||||
mapping.setMerchant(merchant);
|
mapping.setMerchant(merchant);
|
||||||
mapping.setRawCategory(rawCategoryKey);
|
mapping.setRawCategory(rawCategoryKey.trim());
|
||||||
mapping.setEnabled(true);
|
mapping.setEnabled(true);
|
||||||
|
|
||||||
// SOURCE OF TRUTH
|
// SOURCE OF TRUTH (builder slot mapping)
|
||||||
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
mapping.setCanonicalPartRole(mappedPartRole.trim());
|
||||||
|
|
||||||
merchantCategoryMapRepository.save(mapping);
|
merchantCategoryMapRepository.save(mapping);
|
||||||
@@ -83,24 +95,134 @@ public class MappingAdminService {
|
|||||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual “apply mapping to products” (no mapping row changes).
|
|
||||||
*
|
|
||||||
* @return number of products updated
|
|
||||||
*/
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
|
||||||
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
|
||||||
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
if (rawCategoryKey == null || rawCategoryKey.isBlank())
|
||||||
throw new IllegalArgumentException("rawCategoryKey is required");
|
throw new IllegalArgumentException("rawCategoryKey is required");
|
||||||
|
|
||||||
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateInputs(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
|
// ==========================================
|
||||||
if (merchantId == null
|
// 2) NEW: Options endpoint for Catalog UI
|
||||||
|| rawCategoryKey == null || rawCategoryKey.isBlank()
|
// ==========================================
|
||||||
|| mappedPartRole == null || mappedPartRole.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
|
@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<RawCategoryMappingRowDto> 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<Object[]> 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<MerchantCategoryMap> 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,10 @@ package group.goforward.battlbuilder.services;
|
|||||||
|
|
||||||
public interface ReclassificationService {
|
public interface ReclassificationService {
|
||||||
int reclassifyPendingForMerchant(Integer merchantId);
|
int reclassifyPendingForMerchant(Integer merchantId);
|
||||||
|
|
||||||
|
// Existing: apply canonical_part_role mapping to products
|
||||||
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
|
||||||
|
|
||||||
|
// NEW: apply canonical_category_id mapping to products
|
||||||
|
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ public class ReclassificationServiceImpl implements ReclassificationService {
|
|||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
|
|
||||||
|
// ✅ Keep ONE constructor. Spring will inject both deps.
|
||||||
public ReclassificationServiceImpl(
|
public ReclassificationServiceImpl(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
MerchantCategoryMappingService merchantCategoryMappingService
|
MerchantCategoryMappingService merchantCategoryMappingService
|
||||||
@@ -30,6 +31,28 @@ public class ReclassificationServiceImpl implements ReclassificationService {
|
|||||||
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
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,
|
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
|
||||||
* using ONLY merchant_category_map (no rules, no inference).
|
* using ONLY merchant_category_map (no rules, no inference).
|
||||||
@@ -134,6 +157,10 @@ public class ReclassificationServiceImpl implements ReclassificationService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------
|
||||||
|
|
||||||
private String normalizePlatformOrNull(String platform) {
|
private String normalizePlatformOrNull(String platform) {
|
||||||
if (platform == null) return null;
|
if (platform == null) return null;
|
||||||
String t = platform.trim();
|
String t = platform.trim();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package group.goforward.battlbuilder.web.admin;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.services.MappingAdminService;
|
import group.goforward.battlbuilder.services.MappingAdminService;
|
||||||
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -63,4 +65,56 @@ public class AdminMappingController {
|
|||||||
"updatedProducts", updated
|
"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<RawCategoryMappingRowDto> 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<Map<String, Object>> 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()
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record MappingOptionsDto(
|
||||||
|
List<MerchantOptionDto> merchants,
|
||||||
|
List<CanonicalCategoryOptionDto> canonicalCategories
|
||||||
|
) {
|
||||||
|
public record MerchantOptionDto(Integer id, String name) {}
|
||||||
|
public record CanonicalCategoryOptionDto(Integer id, String name, String slug) {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
Reference in New Issue
Block a user