mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-21 01:01:05 -05:00
holy shit. fixed a lot. new rules engine driven by db. project runs
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
package group.goforward.battlbuilder.classification;
|
package group.goforward.battlbuilder.catalog.classification;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleRule;
|
import group.goforward.battlbuilder.model.PartRoleRule;
|
||||||
import group.goforward.battlbuilder.repos.PartRoleRuleRepository;
|
import group.goforward.battlbuilder.repos.PartRoleRuleRepository;
|
||||||
@@ -68,7 +68,7 @@ public class PartRoleResolver {
|
|||||||
if (role == null) return null;
|
if (role == null) return null;
|
||||||
String t = role.trim();
|
String t = role.trim();
|
||||||
if (t.isEmpty()) return null;
|
if (t.isEmpty()) return null;
|
||||||
return t.toLowerCase(Locale.ROOT);
|
return t.toLowerCase(Locale.ROOT).replace('_','-');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizePlatform(String platform) {
|
private static String normalizePlatform(String platform) {
|
||||||
|
|||||||
@@ -2,57 +2,138 @@ package group.goforward.battlbuilder.catalog.classification;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.model.PlatformRule;
|
import group.goforward.battlbuilder.model.PlatformRule;
|
||||||
import group.goforward.battlbuilder.repos.PlatformRuleRepository;
|
import group.goforward.battlbuilder.repos.PlatformRuleRepository;
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.Comparator;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
|
||||||
|
* using explicit DB-backed rules.
|
||||||
|
*
|
||||||
|
* Conservative approach:
|
||||||
|
* - If a rule matches, return its target_platform
|
||||||
|
* - If nothing matches, return null and let the caller decide fallback behavior
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class PlatformResolver {
|
public class PlatformResolver {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
|
||||||
|
|
||||||
private final PlatformRuleRepository ruleRepository;
|
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
|
||||||
private List<CompiledPlatformRule> compiledRules;
|
|
||||||
|
|
||||||
public PlatformResolver(PlatformRuleRepository ruleRepository) {
|
private final PlatformRuleRepository repo;
|
||||||
this.ruleRepository = ruleRepository;
|
private final List<CompiledRule> rules = new ArrayList<>();
|
||||||
|
|
||||||
|
public PlatformResolver(PlatformRuleRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void loadRules() {
|
public void load() {
|
||||||
List<PlatformRule> activeRules = ruleRepository.findByActiveTrueOrderByPriorityDesc();
|
rules.clear();
|
||||||
this.compiledRules = activeRules.stream()
|
|
||||||
.map(CompiledPlatformRule::fromEntity)
|
|
||||||
.sorted(Comparator.comparingInt(CompiledPlatformRule::getPriority).reversed())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
log.info("Loaded {} platform rules", compiledRules.size());
|
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
for (PlatformRule r : active) {
|
||||||
* Resolves final platform.
|
try {
|
||||||
* @param basePlatform platform from merchant mapping (may be null)
|
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
|
||||||
*/
|
Pattern name = compileNullable(r.getNameRegex());
|
||||||
public String resolve(String basePlatform, ProductContext ctx) {
|
String target = normalizePlatform(r.getTargetPlatform());
|
||||||
String platform = basePlatform;
|
|
||||||
|
|
||||||
for (CompiledPlatformRule rule : compiledRules) {
|
// If a rule has no matchers, it's useless — skip it.
|
||||||
if (rule.matches(ctx)) {
|
if (rawCat == null && name == null) {
|
||||||
String newPlatform = rule.getTargetPlatform();
|
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
|
||||||
if (platform == null || !platform.equalsIgnoreCase(newPlatform)) {
|
continue;
|
||||||
log.debug("Platform override: '{}' -> '{}' for product '{}'",
|
|
||||||
platform, newPlatform, ctx.getName());
|
|
||||||
}
|
}
|
||||||
platform = newPlatform;
|
|
||||||
break; // first matching high-priority rule wins
|
if (target == null || target.isBlank()) {
|
||||||
|
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.add(new CompiledRule(
|
||||||
|
r.getId(),
|
||||||
|
r.getMerchantId(),
|
||||||
|
r.getBrandId(),
|
||||||
|
rawCat,
|
||||||
|
name,
|
||||||
|
target
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return platform;
|
log.info("Loaded {} platform rules", rules.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
|
||||||
|
*/
|
||||||
|
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
|
||||||
|
String text = safe(productName) + " " + safe(rawCategoryKey);
|
||||||
|
|
||||||
|
for (CompiledRule r : rules) {
|
||||||
|
if (!r.appliesToMerchant(merchantId)) continue;
|
||||||
|
if (!r.appliesToBrand(brandId)) continue;
|
||||||
|
|
||||||
|
if (r.matches(text)) {
|
||||||
|
return r.targetPlatform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
private static Pattern compileNullable(String regex) {
|
||||||
|
if (regex == null || regex.isBlank()) return null;
|
||||||
|
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizePlatform(String platform) {
|
||||||
|
if (platform == null) return null;
|
||||||
|
String t = platform.trim();
|
||||||
|
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String s) {
|
||||||
|
return s == null ? "" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Internal model
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
private record CompiledRule(
|
||||||
|
Long id,
|
||||||
|
Long merchantId,
|
||||||
|
Long brandId,
|
||||||
|
Pattern rawCategoryPattern,
|
||||||
|
Pattern namePattern,
|
||||||
|
String targetPlatform
|
||||||
|
) {
|
||||||
|
boolean appliesToMerchant(Long merchantId) {
|
||||||
|
return this.merchantId == null || this.merchantId.equals(merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean appliesToBrand(Long brandId) {
|
||||||
|
return this.brandId == null || this.brandId.equals(brandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean matches(String text) {
|
||||||
|
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
|
||||||
|
if (namePattern != null && namePattern.matcher(text).find()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package group.goforward.battlbuilder.controllers;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.PartRoleMapping;
|
||||||
|
import group.goforward.battlbuilder.repos.PartCategoryRepository;
|
||||||
|
import group.goforward.battlbuilder.repos.PartRoleMappingRepository;
|
||||||
|
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/builder")
|
||||||
|
@CrossOrigin
|
||||||
|
public class BuilderBootstrapController {
|
||||||
|
|
||||||
|
private final PartCategoryRepository partCategoryRepository;
|
||||||
|
private final PartRoleMappingRepository mappingRepository;
|
||||||
|
|
||||||
|
public BuilderBootstrapController(
|
||||||
|
PartCategoryRepository partCategoryRepository,
|
||||||
|
PartRoleMappingRepository mappingRepository
|
||||||
|
) {
|
||||||
|
this.partCategoryRepository = partCategoryRepository;
|
||||||
|
this.mappingRepository = mappingRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder bootstrap payload.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - categories: ordered list for UI navigation
|
||||||
|
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
|
||||||
|
* - categoryRoles: categorySlug -> normalized partRoles (derived)
|
||||||
|
*/
|
||||||
|
@GetMapping("/bootstrap")
|
||||||
|
public BuilderBootstrapDto bootstrap(
|
||||||
|
@RequestParam(defaultValue = "AR-15") String platform
|
||||||
|
) {
|
||||||
|
final String platformNorm = normalizePlatform(platform);
|
||||||
|
|
||||||
|
// 1) Categories in display order
|
||||||
|
List<PartCategoryDto> categories = partCategoryRepository
|
||||||
|
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
|
||||||
|
.stream()
|
||||||
|
.map(pc -> new PartCategoryDto(
|
||||||
|
pc.getId(),
|
||||||
|
pc.getSlug(),
|
||||||
|
pc.getName(),
|
||||||
|
pc.getDescription(),
|
||||||
|
pc.getGroupName(),
|
||||||
|
pc.getSortOrder()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 2) Role -> CategorySlug mapping (platform-scoped)
|
||||||
|
// Normalize keys to kebab-case so the UI can treat roles consistently.
|
||||||
|
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
List<PartRoleMapping> mappings = mappingRepository
|
||||||
|
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
|
||||||
|
|
||||||
|
for (PartRoleMapping m : mappings) {
|
||||||
|
String roleKey = normalizePartRole(m.getPartRole());
|
||||||
|
if (roleKey == null || roleKey.isBlank()) continue;
|
||||||
|
|
||||||
|
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
|
||||||
|
|
||||||
|
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
|
||||||
|
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) CategorySlug -> Roles (derived)
|
||||||
|
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
|
||||||
|
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePartRole(String role) {
|
||||||
|
if (role == null) return null;
|
||||||
|
String r = role.trim();
|
||||||
|
if (r.isEmpty()) return null;
|
||||||
|
return r.toLowerCase(Locale.ROOT).replace('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePlatform(String platform) {
|
||||||
|
if (platform == null) return "AR-15";
|
||||||
|
String p = platform.trim();
|
||||||
|
if (p.isEmpty()) return "AR-15";
|
||||||
|
// normalize to AR-15 / AR-10 style
|
||||||
|
return p.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BuilderBootstrapDto(
|
||||||
|
String platform,
|
||||||
|
List<PartCategoryDto> categories,
|
||||||
|
Map<String, String> partRoleMap,
|
||||||
|
Map<String, List<String>> categoryRoles
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/imports")
|
@RequestMapping("/api/admin/imports")
|
||||||
@CrossOrigin(origins = "http://localhost:3000")
|
@CrossOrigin(origins = "http://localhost:3000")
|
||||||
public class ImportController {
|
public class ImportController {
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.stream.Collectors;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/merchant-category-mappings")
|
@RequestMapping("/api/admin/merchant-category-mappings")
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class MerchantCategoryMappingController {
|
public class MerchantCategoryMappingController {
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class MerchantDebugController {
|
|||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/debug/merchants")
|
@GetMapping("/api/admin/debug/merchants")
|
||||||
public List<Merchant> listMerchants() {
|
public List<Merchant> listMerchants() {
|
||||||
return merchantRepository.findAll();
|
return merchantRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ public class ProductController {
|
|||||||
/**
|
/**
|
||||||
* List products for the builder, filterable by platform + partRoles.
|
* List products for the builder, filterable by platform + partRoles.
|
||||||
*
|
*
|
||||||
* GET /api/products?platform=AR-15&partRoles=UPPER&partRoles=BARREL
|
* Examples:
|
||||||
|
* - GET /api/products?platform=AR-15
|
||||||
|
* - GET /api/products?platform=AR-15&partRoles=upper-receiver&partRoles=upper
|
||||||
|
* - GET /api/products?platform=ALL (no platform filter)
|
||||||
|
* - GET /api/products?platform=ALL&partRoles=magazine
|
||||||
*/
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
@@ -45,18 +49,27 @@ public class ProductController {
|
|||||||
@RequestParam(defaultValue = "AR-15") String platform,
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||||
) {
|
) {
|
||||||
|
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||||
|
|
||||||
long started = System.currentTimeMillis();
|
long started = System.currentTimeMillis();
|
||||||
System.out.println("getProducts: start, platform=" + platform +
|
System.out.println("getProducts: start, platform=" + platform +
|
||||||
|
", allPlatforms=" + allPlatforms +
|
||||||
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
||||||
|
|
||||||
// 1) Load products (with brand pre-fetched)
|
// 1) Load products (with brand pre-fetched)
|
||||||
long tProductsStart = System.currentTimeMillis();
|
long tProductsStart = System.currentTimeMillis();
|
||||||
List<Product> products;
|
List<Product> products;
|
||||||
|
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
products = productRepository.findByPlatformWithBrand(platform);
|
products = allPlatforms
|
||||||
|
? productRepository.findAllWithBrand()
|
||||||
|
: productRepository.findByPlatformWithBrand(platform);
|
||||||
} else {
|
} else {
|
||||||
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
products = allPlatforms
|
||||||
|
? productRepository.findByPartRoleInWithBrand(partRoles)
|
||||||
|
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
long tProductsEnd = System.currentTimeMillis();
|
long tProductsEnd = System.currentTimeMillis();
|
||||||
System.out.println("getProducts: loaded products: " +
|
System.out.println("getProducts: loaded products: " +
|
||||||
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
||||||
@@ -75,6 +88,7 @@ public class ProductController {
|
|||||||
|
|
||||||
List<ProductOffer> allOffers =
|
List<ProductOffer> allOffers =
|
||||||
productOfferRepository.findByProductIdIn(productIds);
|
productOfferRepository.findByProductIdIn(productIds);
|
||||||
|
|
||||||
long tOffersEnd = System.currentTimeMillis();
|
long tOffersEnd = System.currentTimeMillis();
|
||||||
System.out.println("getProducts: loaded offers: " +
|
System.out.println("getProducts: loaded offers: " +
|
||||||
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
||||||
@@ -97,6 +111,7 @@ public class ProductController {
|
|||||||
return ProductMapper.toSummary(p, price, buyUrl);
|
return ProductMapper.toSummary(p, price, buyUrl);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
long tMapEnd = System.currentTimeMillis();
|
long tMapEnd = System.currentTimeMillis();
|
||||||
long took = System.currentTimeMillis() - started;
|
long took = System.currentTimeMillis() - started;
|
||||||
|
|
||||||
@@ -110,11 +125,6 @@ public class ProductController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Offers for a single product.
|
|
||||||
*
|
|
||||||
* GET /api/products/{id}/offers
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}/offers")
|
@GetMapping("/{id}/offers")
|
||||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
||||||
@@ -135,9 +145,7 @@ public class ProductController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||||
if (offers == null || offers.isEmpty()) {
|
if (offers == null || offers.isEmpty()) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right now: lowest price wins, regardless of stock
|
// Right now: lowest price wins, regardless of stock
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
@@ -146,11 +154,6 @@ public class ProductController {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single product summary (same shape as list items).
|
|
||||||
*
|
|
||||||
* GET /api/products/{id}
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
||||||
return productRepository.findById(productId)
|
return productRepository.findById(productId)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import group.goforward.battlbuilder.model.CategoryMapping;
|
|||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.PartCategory;
|
import group.goforward.battlbuilder.model.PartCategory;
|
||||||
import group.goforward.battlbuilder.repos.CategoryMappingRepository;
|
import group.goforward.battlbuilder.repos.CategoryMappingRepository;
|
||||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.PartCategoryRepository;
|
import group.goforward.battlbuilder.repos.PartCategoryRepository;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.MerchantCategoryMappingDto;
|
import group.goforward.battlbuilder.web.dto.admin.MerchantCategoryMappingDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.SimpleMerchantDto;
|
import group.goforward.battlbuilder.web.dto.admin.SimpleMerchantDto;
|
||||||
@@ -17,20 +16,17 @@ import java.util.List;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin/category-mappings")
|
@RequestMapping("/api/admin/category-mappings")
|
||||||
@CrossOrigin // you can tighten origins later
|
@CrossOrigin // tighten later
|
||||||
public class AdminCategoryMappingController {
|
public class AdminCategoryMappingController {
|
||||||
|
|
||||||
private final CategoryMappingRepository categoryMappingRepository;
|
private final CategoryMappingRepository categoryMappingRepository;
|
||||||
private final MerchantRepository merchantRepository;
|
|
||||||
private final PartCategoryRepository partCategoryRepository;
|
private final PartCategoryRepository partCategoryRepository;
|
||||||
|
|
||||||
public AdminCategoryMappingController(
|
public AdminCategoryMappingController(
|
||||||
CategoryMappingRepository categoryMappingRepository,
|
CategoryMappingRepository categoryMappingRepository,
|
||||||
MerchantRepository merchantRepository,
|
|
||||||
PartCategoryRepository partCategoryRepository
|
PartCategoryRepository partCategoryRepository
|
||||||
) {
|
) {
|
||||||
this.categoryMappingRepository = categoryMappingRepository;
|
this.categoryMappingRepository = categoryMappingRepository;
|
||||||
this.merchantRepository = merchantRepository;
|
|
||||||
this.partCategoryRepository = partCategoryRepository;
|
this.partCategoryRepository = partCategoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +55,6 @@ public class AdminCategoryMappingController {
|
|||||||
if (merchantId != null) {
|
if (merchantId != null) {
|
||||||
mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId);
|
mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId);
|
||||||
} else {
|
} else {
|
||||||
// fall back to all mappings; you can add a more specific repository method later if desired
|
|
||||||
mappings = categoryMappingRepository.findAll();
|
mappings = categoryMappingRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +72,10 @@ public class AdminCategoryMappingController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single mapping's part_category.
|
* Update a single mapping's part_category.
|
||||||
* POST /api/admin/category-mappings/{id}
|
* PUT /api/admin/category-mappings/{id}
|
||||||
* Body: { "partCategoryId": 24 }
|
* Body: { "partCategoryId": 24 }
|
||||||
*/
|
*/
|
||||||
@PostMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public MerchantCategoryMappingDto updateMapping(
|
public MerchantCategoryMappingDto updateMapping(
|
||||||
@PathVariable Integer id,
|
@PathVariable Integer id,
|
||||||
@RequestBody UpdateMerchantCategoryMappingRequest request
|
@RequestBody UpdateMerchantCategoryMappingRequest request
|
||||||
@@ -106,12 +101,4 @@ public class AdminCategoryMappingController {
|
|||||||
mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null
|
mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@PutMapping("/{id}")
|
|
||||||
public MerchantCategoryMappingDto updateMappingPut(
|
|
||||||
@PathVariable Integer id,
|
|
||||||
@RequestBody UpdateMerchantCategoryMappingRequest request
|
|
||||||
) {
|
|
||||||
// just delegate so POST & PUT behave the same
|
|
||||||
return updateMapping(id, request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import group.goforward.battlbuilder.web.dto.MerchantAdminDto;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/admin/merchants")
|
@RequestMapping("/api/admin/merchants")
|
||||||
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
|
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
|
||||||
public class MerchantAdminController {
|
public class MerchantAdminController {
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package group.goforward.battlbuilder.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "part_role_category_mappings",
|
|
||||||
uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"}))
|
|
||||||
public class PartRoleCategoryMapping {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Integer id;
|
|
||||||
|
|
||||||
@Column(name = "platform", nullable = false)
|
|
||||||
private String platform;
|
|
||||||
|
|
||||||
@Column(name = "part_role", nullable = false)
|
|
||||||
private String partRole;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false)
|
|
||||||
private PartCategory category;
|
|
||||||
|
|
||||||
@Column(name = "notes")
|
|
||||||
private String notes;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
|
||||||
private OffsetDateTime createdAt;
|
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
|
||||||
private OffsetDateTime updatedAt;
|
|
||||||
|
|
||||||
// getters/setters…
|
|
||||||
|
|
||||||
public Integer getId() { return id; }
|
|
||||||
public void setId(Integer id) { this.id = id; }
|
|
||||||
|
|
||||||
public String getPlatform() { return platform; }
|
|
||||||
public void setPlatform(String platform) { this.platform = platform; }
|
|
||||||
|
|
||||||
public String getPartRole() { return partRole; }
|
|
||||||
public void setPartRole(String partRole) { this.partRole = partRole; }
|
|
||||||
|
|
||||||
public PartCategory getCategory() { return category; }
|
|
||||||
public void setCategory(PartCategory category) { this.category = category; }
|
|
||||||
|
|
||||||
public String getNotes() { return notes; }
|
|
||||||
public void setNotes(String notes) { this.notes = notes; }
|
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
|
||||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
|
||||||
|
|
||||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
|
||||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package group.goforward.battlbuilder.repos;
|
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.PartRoleCategoryMapping;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface PartRoleCategoryMappingRepository extends JpaRepository<PartRoleCategoryMapping, Integer> {
|
|
||||||
|
|
||||||
List<PartRoleCategoryMapping> findAllByPlatformOrderByPartRoleAsc(String platform);
|
|
||||||
|
|
||||||
Optional<PartRoleCategoryMapping> findByPlatformAndPartRole(String platform, String partRole);
|
|
||||||
}
|
|
||||||
@@ -8,25 +8,15 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
|
||||||
|
|
||||||
// For resolver: one mapping per platform + partRole
|
// Used by admin screens / lists (case-sensitive, no platform normalization)
|
||||||
Optional<PartRoleMapping> findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
|
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||||
|
|
||||||
|
// Used by builder/bootstrap flows (case-insensitive)
|
||||||
|
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
|
||||||
|
|
||||||
|
// Used by resolvers when mapping a single role (case-insensitive)
|
||||||
|
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
|
||||||
String platform,
|
String platform,
|
||||||
String partRole
|
String partRole
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional: debug / inspection
|
|
||||||
List<PartRoleMapping> findAllByPlatformAndPartRoleAndDeletedAtIsNull(
|
|
||||||
String platform,
|
|
||||||
String partRole
|
|
||||||
);
|
|
||||||
|
|
||||||
// This is the one PartRoleMappingService needs
|
|
||||||
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(
|
|
||||||
String platform
|
|
||||||
);
|
|
||||||
|
|
||||||
List<PartRoleMapping> findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
|
|
||||||
String platform,
|
|
||||||
String slug
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,13 @@ package group.goforward.battlbuilder.repos;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.model.PlatformRule;
|
import group.goforward.battlbuilder.model.PlatformRule;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
|
||||||
|
|
||||||
List<PlatformRule> findByActiveTrueOrderByPriorityDesc();
|
// Active rules, highest priority first (tie-breaker: id asc for stability)
|
||||||
|
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,23 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
@Param("roles") List<String> roles
|
@Param("roles") List<String> roles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT p
|
||||||
|
FROM Product p
|
||||||
|
JOIN FETCH p.brand b
|
||||||
|
WHERE p.deletedAt IS NULL
|
||||||
|
""")
|
||||||
|
List<Product> findAllWithBrand();
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT p
|
||||||
|
FROM Product p
|
||||||
|
JOIN FETCH p.brand b
|
||||||
|
WHERE p.partRole IN :roles
|
||||||
|
AND p.deletedAt IS NULL
|
||||||
|
""")
|
||||||
|
List<Product> findByPartRoleInWithBrand(@Param("roles") List<String> roles);
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// Used by /api/gunbuilder/test-products-db
|
// Used by /api/gunbuilder/test-products-db
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
@@ -210,5 +227,7 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
|||||||
and p.import_status = 'PENDING_MAPPING'
|
and p.import_status = 'PENDING_MAPPING'
|
||||||
and p.deleted_at is null
|
and p.deleted_at is null
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
|
|
||||||
|
|
||||||
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
List<Product> findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
|
||||||
}
|
}
|
||||||
@@ -21,18 +21,15 @@ public class PartCategoryResolverService {
|
|||||||
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
|
||||||
*/
|
*/
|
||||||
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
|
||||||
|
if (platform == null || partRole == null) return Optional.empty();
|
||||||
|
|
||||||
if (platform == null || partRole == null) {
|
String p = platform.trim();
|
||||||
return Optional.empty();
|
String r = partRole.trim();
|
||||||
}
|
|
||||||
|
|
||||||
// Keep things case-sensitive since your DB values are already uppercase.
|
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
|
||||||
Optional<PartRoleMapping> mappingOpt =
|
|
||||||
partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
|
|
||||||
platform,
|
|
||||||
partRole
|
|
||||||
);
|
|
||||||
|
|
||||||
return mappingOpt.map(PartRoleMapping::getPartCategory);
|
return partRoleMappingRepository
|
||||||
|
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
|
||||||
|
.map(PartRoleMapping::getPartCategory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package group.goforward.battlbuilder.services.impl;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.classification.PartRoleResolver;
|
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
import group.goforward.battlbuilder.model.Merchant;
|
import group.goforward.battlbuilder.model.Merchant;
|
||||||
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
import group.goforward.battlbuilder.model.MerchantCategoryMap;
|
||||||
@@ -40,8 +40,15 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
// Part role from mapping (if present), else rules, else infer
|
// Part role from mapping (if present), else rules, else infer
|
||||||
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal)
|
String partRole = resolvePartRoleFromMapping(merchant, rawCategoryKeyFinal)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKeyFinal);
|
String resolved = partRoleResolver.resolve(
|
||||||
return resolved != null ? resolved : inferPartRole(row);
|
platformFinal,
|
||||||
|
row.productName(),
|
||||||
|
rawCategoryKeyFinal
|
||||||
|
);
|
||||||
|
if (resolved != null && !resolved.isBlank()) return resolved;
|
||||||
|
|
||||||
|
// ✅ IMPORTANT: pass rawCategoryKey so inference can see "Complete Uppers" etc.
|
||||||
|
return inferPartRole(row, rawCategoryKeyFinal);
|
||||||
});
|
});
|
||||||
|
|
||||||
partRole = normalizePartRole(partRole);
|
partRole = normalizePartRole(partRole);
|
||||||
@@ -58,7 +65,7 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
);
|
);
|
||||||
|
|
||||||
return mappings.stream()
|
return mappings.stream()
|
||||||
.map(m -> m.getMappedPartRole())
|
.map(MerchantCategoryMap::getMappedPartRole)
|
||||||
.filter(r -> r != null && !r.isBlank())
|
.filter(r -> r != null && !r.isBlank())
|
||||||
.findFirst();
|
.findFirst();
|
||||||
}
|
}
|
||||||
@@ -84,37 +91,111 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String inferPlatform(MerchantFeedRow row) {
|
private String inferPlatform(MerchantFeedRow row) {
|
||||||
String department = coalesce(
|
String blob = String.join(" ",
|
||||||
trimOrNull(row.department()),
|
coalesce(trimOrNull(row.department()), ""),
|
||||||
trimOrNull(row.category())
|
coalesce(trimOrNull(row.category()), ""),
|
||||||
);
|
coalesce(trimOrNull(row.subCategory()), "")
|
||||||
if (department == null) return null;
|
).toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
String lower = department.toLowerCase(Locale.ROOT);
|
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
|
||||||
if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15";
|
if (blob.contains("ar-10") || blob.contains("ar10")) return "AR-10";
|
||||||
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
|
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
|
||||||
if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47";
|
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
|
||||||
|
|
||||||
// default
|
return "AR-15"; // safe default
|
||||||
return "AR-15";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String inferPartRole(MerchantFeedRow row) {
|
/**
|
||||||
String cat = coalesce(
|
* Fallback inference ONLY. Prefer:
|
||||||
trimOrNull(row.subCategory()),
|
* 1) merchant mapping table
|
||||||
trimOrNull(row.category())
|
* 2) PartRoleResolver rules
|
||||||
|
* 3) this method
|
||||||
|
*
|
||||||
|
* Key principle: use rawCategoryKey + productName (not just subCategory),
|
||||||
|
* because merchants often encode the important signal in category paths.
|
||||||
|
*/
|
||||||
|
private String inferPartRole(MerchantFeedRow row, String rawCategoryKey) {
|
||||||
|
String subCat = trimOrNull(row.subCategory());
|
||||||
|
String cat = trimOrNull(row.category());
|
||||||
|
String dept = trimOrNull(row.department());
|
||||||
|
String name = trimOrNull(row.productName());
|
||||||
|
|
||||||
|
// Combine ALL possible signals
|
||||||
|
String combined = coalesce(
|
||||||
|
rawCategoryKey,
|
||||||
|
joinNonNull(" > ", dept, cat, subCat),
|
||||||
|
cat,
|
||||||
|
subCat
|
||||||
);
|
);
|
||||||
if (cat == null) return null;
|
|
||||||
|
|
||||||
String lower = cat.toLowerCase(Locale.ROOT);
|
String combinedLower = combined == null ? "" : combined.toLowerCase(Locale.ROOT);
|
||||||
|
String nameLower = name == null ? "" : name.toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
if (lower.contains("handguard") || lower.contains("rail")) return "handguard";
|
// ---------- HIGH PRIORITY: COMPLETE ASSEMBLIES ----------
|
||||||
if (lower.contains("barrel")) return "barrel";
|
// rawCategoryKey from your DB shows "Ar-15 Complete Uppers" — grab that first.
|
||||||
if (lower.contains("upper")) return "upper-receiver";
|
boolean looksLikeCompleteUpper =
|
||||||
if (lower.contains("lower")) return "lower-receiver";
|
combinedLower.contains("complete upper") ||
|
||||||
if (lower.contains("magazine") || lower.contains("mag")) return "magazine";
|
combinedLower.contains("complete uppers") ||
|
||||||
if (lower.contains("stock") || lower.contains("buttstock")) return "stock";
|
combinedLower.contains("upper receiver assembly") ||
|
||||||
if (lower.contains("grip")) return "grip";
|
combinedLower.contains("barreled upper") ||
|
||||||
|
nameLower.contains("complete upper") ||
|
||||||
|
nameLower.contains("complete upper receiver") ||
|
||||||
|
nameLower.contains("barreled upper") ||
|
||||||
|
nameLower.contains("upper receiver assembly");
|
||||||
|
|
||||||
|
if (looksLikeCompleteUpper) return "complete-upper";
|
||||||
|
|
||||||
|
boolean looksLikeCompleteLower =
|
||||||
|
combinedLower.contains("complete lower") ||
|
||||||
|
combinedLower.contains("complete lowers") ||
|
||||||
|
nameLower.contains("complete lower");
|
||||||
|
|
||||||
|
if (looksLikeCompleteLower) return "complete-lower";
|
||||||
|
|
||||||
|
// ---------- RECEIVERS ----------
|
||||||
|
// If we see "stripped upper", prefer upper-receiver. Otherwise "upper" can be generic.
|
||||||
|
boolean looksLikeStrippedUpper =
|
||||||
|
combinedLower.contains("stripped upper") ||
|
||||||
|
nameLower.contains("stripped upper");
|
||||||
|
|
||||||
|
if (looksLikeStrippedUpper) return "upper-receiver";
|
||||||
|
|
||||||
|
boolean looksLikeStrippedLower =
|
||||||
|
combinedLower.contains("stripped lower") ||
|
||||||
|
nameLower.contains("stripped lower");
|
||||||
|
|
||||||
|
if (looksLikeStrippedLower) return "lower-receiver";
|
||||||
|
|
||||||
|
// ---------- COMMON PARTS ----------
|
||||||
|
if (combinedLower.contains("handguard") || combinedLower.contains("rail")) return "handguard";
|
||||||
|
if (combinedLower.contains("barrel")) return "barrel";
|
||||||
|
if (combinedLower.contains("gas block") || combinedLower.contains("gas-block") || combinedLower.contains("gasblock")) return "gas-block";
|
||||||
|
if (combinedLower.contains("gas tube") || combinedLower.contains("gas-tube") || combinedLower.contains("gastube")) return "gas-tube";
|
||||||
|
if (combinedLower.contains("muzzle") || combinedLower.contains("brake") || combinedLower.contains("compensator")) return "muzzle-device";
|
||||||
|
if (combinedLower.contains("bolt carrier") || combinedLower.contains("bolt-carrier") || combinedLower.contains("bcg")) return "bcg";
|
||||||
|
if (combinedLower.contains("charging handle") || combinedLower.contains("charging-handle")) return "charging-handle";
|
||||||
|
|
||||||
|
if (combinedLower.contains("lower parts") || combinedLower.contains("lower-parts") || combinedLower.contains("lpk")) return "lower-parts";
|
||||||
|
if (combinedLower.contains("trigger")) return "trigger";
|
||||||
|
if (combinedLower.contains("pistol grip") || combinedLower.contains("grip")) return "grip";
|
||||||
|
if (combinedLower.contains("safety") || combinedLower.contains("selector")) return "safety";
|
||||||
|
if (combinedLower.contains("buffer")) return "buffer";
|
||||||
|
if (combinedLower.contains("stock") || combinedLower.contains("buttstock") || combinedLower.contains("brace")) return "stock";
|
||||||
|
|
||||||
|
if (combinedLower.contains("magazine") || combinedLower.contains(" mag ") || combinedLower.equals("mag")) return "magazine";
|
||||||
|
if (combinedLower.contains("sight")) return "sights";
|
||||||
|
if (combinedLower.contains("optic") || combinedLower.contains("scope")) return "optic";
|
||||||
|
if (combinedLower.contains("suppress")) return "suppressor";
|
||||||
|
if (combinedLower.contains("light") || combinedLower.contains("laser")) return "weapon-light";
|
||||||
|
if (combinedLower.contains("bipod")) return "bipod";
|
||||||
|
if (combinedLower.contains("sling")) return "sling";
|
||||||
|
if (combinedLower.contains("foregrip") || combinedLower.contains("vertical grip") || combinedLower.contains("angled")) return "foregrip";
|
||||||
|
if (combinedLower.contains("tool") || combinedLower.contains("wrench") || combinedLower.contains("armorer")) return "tools";
|
||||||
|
|
||||||
|
// ---------- LAST RESORT ----------
|
||||||
|
// If it says "upper" but NOT complete upper, keep it generic
|
||||||
|
if (combinedLower.contains("upper")) return "upper";
|
||||||
|
if (combinedLower.contains("lower")) return "lower";
|
||||||
|
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -139,4 +220,15 @@ public class CategoryClassificationServiceImpl implements CategoryClassification
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String joinNonNull(String sep, String... parts) {
|
||||||
|
if (parts == null || parts.length == 0) return null;
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p == null || p.isBlank()) continue;
|
||||||
|
if (!sb.isEmpty()) sb.append(sep);
|
||||||
|
sb.append(p.trim());
|
||||||
|
}
|
||||||
|
return sb.isEmpty() ? null : sb.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
|
|
||||||
// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted
|
|
||||||
|
|
||||||
package group.goforward.battlbuilder.services.impl;
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
import group.goforward.battlbuilder.imports.MerchantFeedRow;
|
||||||
@@ -13,8 +10,7 @@ import group.goforward.battlbuilder.repos.BrandRepository;
|
|||||||
import group.goforward.battlbuilder.repos.MerchantRepository;
|
import group.goforward.battlbuilder.repos.MerchantRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
import group.goforward.battlbuilder.catalog.classification.PlatformResolver;
|
import group.goforward.battlbuilder.services.CategoryClassificationService;
|
||||||
import group.goforward.battlbuilder.catalog.classification.ProductContext;
|
|
||||||
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
import group.goforward.battlbuilder.services.MerchantFeedImportService;
|
||||||
import org.apache.commons.csv.CSVFormat;
|
import org.apache.commons.csv.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVParser;
|
import org.apache.commons.csv.CSVParser;
|
||||||
@@ -38,6 +34,13 @@ import java.util.*;
|
|||||||
*
|
*
|
||||||
* - importMerchantFeed: full ETL (products + offers)
|
* - importMerchantFeed: full ETL (products + offers)
|
||||||
* - syncOffersOnly: only refresh offers/prices/stock from an offers feed
|
* - syncOffersOnly: only refresh offers/prices/stock from an offers feed
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* Classification (platform + partRole + rawCategoryKey) must run through CategoryClassificationService
|
||||||
|
* so we respect:
|
||||||
|
* 1) merchant_category_mappings (admin UI mapping)
|
||||||
|
* 2) rule-based resolver
|
||||||
|
* 3) fallback inference
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -48,21 +51,21 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private final MerchantRepository merchantRepository;
|
private final MerchantRepository merchantRepository;
|
||||||
private final BrandRepository brandRepository;
|
private final BrandRepository brandRepository;
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final PlatformResolver platformResolver;
|
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
private final CategoryClassificationService categoryClassificationService;
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(
|
public MerchantFeedImportServiceImpl(
|
||||||
MerchantRepository merchantRepository,
|
MerchantRepository merchantRepository,
|
||||||
BrandRepository brandRepository,
|
BrandRepository brandRepository,
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
PlatformResolver platformResolver,
|
ProductOfferRepository productOfferRepository,
|
||||||
ProductOfferRepository productOfferRepository
|
CategoryClassificationService categoryClassificationService
|
||||||
) {
|
) {
|
||||||
this.merchantRepository = merchantRepository;
|
this.merchantRepository = merchantRepository;
|
||||||
this.brandRepository = brandRepository;
|
this.brandRepository = brandRepository;
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.platformResolver = platformResolver;
|
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
this.categoryClassificationService = categoryClassificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -83,9 +86,15 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
for (MerchantFeedRow row : rows) {
|
for (MerchantFeedRow row : rows) {
|
||||||
Brand brand = resolveBrand(row);
|
Brand brand = resolveBrand(row);
|
||||||
Product p = upsertProduct(merchant, brand, row);
|
Product p = upsertProduct(merchant, brand, row);
|
||||||
|
|
||||||
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
|
log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}",
|
||||||
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
merchant.setLastFullImportAt(OffsetDateTime.now());
|
||||||
|
merchantRepository.save(merchant);
|
||||||
|
|
||||||
|
log.info("Completed full import for merchantId={} ({} rows processed)", merchantId, rows.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -93,9 +102,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||||
log.debug("Upserting product for brand={}, sku={}, name={}",
|
|
||||||
brand.getName(), row.sku(), row.productName());
|
|
||||||
|
|
||||||
String mpn = trimOrNull(row.manufacturerId());
|
String mpn = trimOrNull(row.manufacturerId());
|
||||||
String upc = trimOrNull(row.sku()); // placeholder until a real UPC column exists
|
String upc = trimOrNull(row.sku()); // placeholder until a real UPC column exists
|
||||||
|
|
||||||
@@ -120,6 +126,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
brand.getName(), mpn, upc, candidates.get(0).getId());
|
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||||
}
|
}
|
||||||
p = candidates.get(0);
|
p = candidates.get(0);
|
||||||
|
// keep brand stable (but ensure it's set)
|
||||||
|
if (p.getBrand() == null) p.setBrand(brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProductFromRow(p, merchant, row, isNew);
|
updateProductFromRow(p, merchant, row, isNew);
|
||||||
@@ -135,6 +143,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
Merchant merchant,
|
Merchant merchant,
|
||||||
MerchantFeedRow row,
|
MerchantFeedRow row,
|
||||||
boolean isNew) {
|
boolean isNew) {
|
||||||
|
|
||||||
// ---------- NAME ----------
|
// ---------- NAME ----------
|
||||||
String name = coalesce(
|
String name = coalesce(
|
||||||
trimOrNull(row.productName()),
|
trimOrNull(row.productName()),
|
||||||
@@ -142,32 +151,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
trimOrNull(row.longDescription()),
|
trimOrNull(row.longDescription()),
|
||||||
trimOrNull(row.sku())
|
trimOrNull(row.sku())
|
||||||
);
|
);
|
||||||
if (name == null) {
|
if (name == null) name = "Unknown Product";
|
||||||
name = "Unknown Product";
|
|
||||||
}
|
|
||||||
p.setName(name);
|
p.setName(name);
|
||||||
|
|
||||||
// ---------- SLUG ----------
|
// ---------- SLUG ----------
|
||||||
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
||||||
String baseForSlug = coalesce(
|
String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku()));
|
||||||
trimOrNull(name),
|
if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis();
|
||||||
trimOrNull(row.sku())
|
|
||||||
);
|
|
||||||
if (baseForSlug == null) {
|
|
||||||
baseForSlug = "product-" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
String slug = baseForSlug
|
String slug = baseForSlug
|
||||||
.toLowerCase()
|
.toLowerCase(Locale.ROOT)
|
||||||
.replaceAll("[^a-z0-9]+", "-")
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
.replaceAll("(^-|-$)", "");
|
.replaceAll("(^-|-$)", "");
|
||||||
|
|
||||||
if (slug.isBlank()) {
|
if (slug.isBlank()) slug = "product-" + System.currentTimeMillis();
|
||||||
slug = "product-" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
String uniqueSlug = generateUniqueSlug(slug);
|
p.setSlug(generateUniqueSlug(slug));
|
||||||
p.setSlug(uniqueSlug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- DESCRIPTIONS ----------
|
// ---------- DESCRIPTIONS ----------
|
||||||
@@ -183,56 +182,30 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
p.setMainImageUrl(mainImage);
|
p.setMainImageUrl(mainImage);
|
||||||
|
|
||||||
// ---------- IDENTIFIERS ----------
|
// ---------- IDENTIFIERS ----------
|
||||||
String mpn = coalesce(
|
String mpn = coalesce(trimOrNull(row.manufacturerId()), trimOrNull(row.sku()));
|
||||||
trimOrNull(row.manufacturerId()),
|
|
||||||
trimOrNull(row.sku())
|
|
||||||
);
|
|
||||||
p.setMpn(mpn);
|
p.setMpn(mpn);
|
||||||
p.setUpc(null); // placeholder
|
p.setUpc(null); // placeholder
|
||||||
|
|
||||||
// ---------- RAW CATEGORY KEY ----------
|
// ---------- CLASSIFICATION (rawCategoryKey + platform + partRole) ----------
|
||||||
String rawCategoryKey = buildRawCategoryKey(row);
|
CategoryClassificationService.Result r = categoryClassificationService.classify(merchant, row);
|
||||||
p.setRawCategoryKey(rawCategoryKey);
|
|
||||||
|
|
||||||
// ---------- PLATFORM (base heuristic + rule resolver) ----------
|
// Always persist the rawCategoryKey coming out of classification (consistent keying)
|
||||||
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
p.setRawCategoryKey(r.rawCategoryKey());
|
||||||
String basePlatform = inferPlatform(row);
|
|
||||||
|
|
||||||
Long merchantId = merchant.getId() != null
|
// Respect platformLocked: if locked and platform already present, keep it.
|
||||||
? merchant.getId().longValue()
|
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked()) || p.getPlatform() == null || p.getPlatform().isBlank()) {
|
||||||
: null;
|
String platform = (r.platform() == null || r.platform().isBlank()) ? "AR-15" : r.platform();
|
||||||
|
p.setPlatform(platform);
|
||||||
Long brandId = (p.getBrand() != null && p.getBrand().getId() != null)
|
|
||||||
? p.getBrand().getId().longValue()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
ProductContext ctx = new ProductContext(
|
|
||||||
merchantId,
|
|
||||||
brandId,
|
|
||||||
rawCategoryKey,
|
|
||||||
p.getName()
|
|
||||||
);
|
|
||||||
|
|
||||||
String resolvedPlatform = platformResolver.resolve(basePlatform, ctx);
|
|
||||||
|
|
||||||
String finalPlatform = resolvedPlatform != null
|
|
||||||
? resolvedPlatform
|
|
||||||
: (basePlatform != null ? basePlatform : "AR-15");
|
|
||||||
|
|
||||||
p.setPlatform(finalPlatform);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- PART ROLE (keyword-based for now) ----------
|
// Part role should always be driven by classification (mapping/rules/inference),
|
||||||
String partRole = inferPartRole(row);
|
// but if something returns null/blank, treat as unknown.
|
||||||
if (partRole == null || partRole.isBlank()) {
|
String partRole = (r.partRole() == null) ? "unknown" : r.partRole().trim();
|
||||||
partRole = "UNKNOWN";
|
if (partRole.isBlank()) partRole = "unknown";
|
||||||
} else {
|
|
||||||
partRole = partRole.trim();
|
|
||||||
}
|
|
||||||
p.setPartRole(partRole);
|
p.setPartRole(partRole);
|
||||||
|
|
||||||
// ---------- IMPORT STATUS ----------
|
// ---------- IMPORT STATUS ----------
|
||||||
if ("UNKNOWN".equalsIgnoreCase(partRole)) {
|
if ("unknown".equalsIgnoreCase(partRole) || "UNKNOWN".equalsIgnoreCase(partRole)) {
|
||||||
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
p.setImportStatus(ImportStatus.PENDING_MAPPING);
|
||||||
} else {
|
} else {
|
||||||
p.setImportStatus(ImportStatus.MAPPED);
|
p.setImportStatus(ImportStatus.MAPPED);
|
||||||
@@ -273,7 +246,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
||||||
|
|
||||||
BigDecimal retail = row.retailPrice();
|
BigDecimal retail = row.retailPrice();
|
||||||
BigDecimal sale = row.salePrice();
|
BigDecimal sale = row.salePrice();
|
||||||
|
|
||||||
BigDecimal effectivePrice;
|
BigDecimal effectivePrice;
|
||||||
BigDecimal originalPrice;
|
BigDecimal originalPrice;
|
||||||
@@ -426,7 +399,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
||||||
// Try a few common delimiters, but only require the SKU header to be present.
|
// Try a few common delimiters, but only require the SKU header to be present.
|
||||||
char[] delimiters = new char[]{'\t', ',', ';', '|'};
|
char[] delimiters = new char[]{'\t', ',', ';', '|'};
|
||||||
List<String> requiredHeaders = Arrays.asList("SKU");
|
List<String> requiredHeaders = Collections.singletonList("SKU");
|
||||||
|
|
||||||
Exception lastException = null;
|
Exception lastException = null;
|
||||||
|
|
||||||
@@ -455,11 +428,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
.setIgnoreSurroundingSpaces(true)
|
.setIgnoreSurroundingSpaces(true)
|
||||||
.setTrim(true)
|
.setTrim(true)
|
||||||
.build();
|
.build();
|
||||||
} else if (headerMap != null) {
|
|
||||||
log.debug("Delimiter '{}' produced headers {} for feed {}",
|
|
||||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)),
|
|
||||||
headerMap.keySet(),
|
|
||||||
feedUrl);
|
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
lastException = ex;
|
lastException = ex;
|
||||||
@@ -467,9 +435,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got here, either all attempts failed or none matched the headers we expected.
|
|
||||||
// Fall back to a sensible default (comma) instead of failing the whole import.
|
|
||||||
log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl);
|
log.warn("Could not auto-detect delimiter for feed {}. Falling back to comma delimiter.", feedUrl);
|
||||||
|
if (lastException != null) {
|
||||||
|
log.debug("Last delimiter detection error:", lastException);
|
||||||
|
}
|
||||||
|
|
||||||
return CSVFormat.DEFAULT.builder()
|
return CSVFormat.DEFAULT.builder()
|
||||||
.setDelimiter(',')
|
.setDelimiter(',')
|
||||||
.setHeader()
|
.setHeader()
|
||||||
@@ -569,12 +539,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String getCsvValue(CSVRecord rec, String header) {
|
private String getCsvValue(CSVRecord rec, String header) {
|
||||||
if (rec == null || header == null) {
|
if (rec == null || header == null) return null;
|
||||||
return null;
|
if (!rec.isMapped(header)) return null;
|
||||||
}
|
|
||||||
if (!rec.isMapped(header)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return rec.get(header);
|
return rec.get(header);
|
||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
@@ -593,9 +560,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
private String coalesce(String... values) {
|
private String coalesce(String... values) {
|
||||||
if (values == null) return null;
|
if (values == null) return null;
|
||||||
for (String v : values) {
|
for (String v : values) {
|
||||||
if (v != null && !v.isBlank()) {
|
if (v != null && !v.isBlank()) return v;
|
||||||
return v;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -609,70 +574,4 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildRawCategoryKey(MerchantFeedRow row) {
|
|
||||||
String dept = trimOrNull(row.department());
|
|
||||||
String cat = trimOrNull(row.category());
|
|
||||||
String sub = trimOrNull(row.subCategory());
|
|
||||||
|
|
||||||
List<String> parts = new ArrayList<>();
|
|
||||||
if (dept != null) parts.add(dept);
|
|
||||||
if (cat != null) parts.add(cat);
|
|
||||||
if (sub != null) parts.add(sub);
|
|
||||||
|
|
||||||
if (parts.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.join(" > ", parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String inferPlatform(MerchantFeedRow row) {
|
|
||||||
String department = coalesce(
|
|
||||||
trimOrNull(row.department()),
|
|
||||||
trimOrNull(row.category())
|
|
||||||
);
|
|
||||||
if (department == null) return null;
|
|
||||||
|
|
||||||
String lower = department.toLowerCase(Locale.ROOT);
|
|
||||||
if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15";
|
|
||||||
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
|
|
||||||
if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47";
|
|
||||||
|
|
||||||
return "AR-15";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String inferPartRole(MerchantFeedRow row) {
|
|
||||||
String cat = coalesce(
|
|
||||||
trimOrNull(row.subCategory()),
|
|
||||||
trimOrNull(row.category())
|
|
||||||
);
|
|
||||||
if (cat == null) return null;
|
|
||||||
|
|
||||||
String lower = cat.toLowerCase(Locale.ROOT);
|
|
||||||
|
|
||||||
if (lower.contains("handguard") || lower.contains("rail")) {
|
|
||||||
return "handguard";
|
|
||||||
}
|
|
||||||
if (lower.contains("barrel")) {
|
|
||||||
return "barrel";
|
|
||||||
}
|
|
||||||
if (lower.contains("upper")) {
|
|
||||||
return "upper-receiver";
|
|
||||||
}
|
|
||||||
if (lower.contains("lower")) {
|
|
||||||
return "lower-receiver";
|
|
||||||
}
|
|
||||||
if (lower.contains("magazine") || lower.contains("mag")) {
|
|
||||||
return "magazine";
|
|
||||||
}
|
|
||||||
if (lower.contains("stock") || lower.contains("buttstock")) {
|
|
||||||
return "stock";
|
|
||||||
}
|
|
||||||
if (lower.contains("grip")) {
|
|
||||||
return "grip";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user