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