shit ton. reworked parts role and canonical categories.

This commit is contained in:
2025-12-29 13:59:41 -05:00
parent 5269ec479b
commit 30ac00ecf9
20 changed files with 1426 additions and 40 deletions

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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, its 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;
}
}

View File

@@ -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
) {}

View File

@@ -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
}
}

View File

@@ -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
) {}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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();
}

View File

@@ -12,6 +12,41 @@ import java.util.Optional;
@Repository
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("""
select mcm.canonicalPartRole
from MerchantCategoryMap mcm
@@ -25,13 +60,4 @@ public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCat
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory
);
// Optional convenience method (you can keep, but service logic will handle platform preference)
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId,
String rawCategory
);
}

View File

@@ -38,4 +38,40 @@ public interface ProductOfferRepository extends JpaRepository<ProductOffer, Inte
where po.product.id = :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
);
}

View File

@@ -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<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
// -------------------------------------------------
@@ -260,6 +378,22 @@ ORDER BY productCount DESC
""")
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
// -------------------------------------------------

View File

@@ -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<PendingMappingBucketDto> listPendingBuckets() {
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
* 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<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);
}
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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<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()
));
}
}

View File

@@ -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) {}
}

View File

@@ -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
) {}