From ed1e08cbfa54e3310258eaa46808a66c2873d9be Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 19 Dec 2025 04:29:25 -0500 Subject: [PATCH] New V1 Products api. it s versioned for the future and has multiple new functions to groups offers into products, a best price response, etc. --- .../controllers/ProductV1Controller.java | 148 ++++++++++ .../impl/MerchantFeedImportServiceImpl.java | 256 ++++++++++++++---- .../web/dto/ProductDetailsDto.java | 53 ++++ .../battlbuilder/web/dto/ProductDto.java | 35 ++- 4 files changed, 432 insertions(+), 60 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java create mode 100644 src/main/java/group/goforward/battlbuilder/web/dto/ProductDetailsDto.java diff --git a/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java new file mode 100644 index 0000000..5015d7b --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/ProductV1Controller.java @@ -0,0 +1,148 @@ +package group.goforward.battlbuilder.controllers; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.model.ProductOffer; +import group.goforward.battlbuilder.repos.ProductOfferRepository; +import group.goforward.battlbuilder.repos.ProductRepository; +import group.goforward.battlbuilder.web.dto.ProductDto; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@CrossOrigin +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductV1Controller( + ProductRepository productRepository, + ProductOfferRepository productOfferRepository + ) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + /** + * List products (v1 summary contract) + * + * Examples: + * - GET /api/v1/products?platform=AR-15 + * - GET /api/v1/products?platform=AR-15&partRoles=complete-upper&partRoles=upper-receiver + * - GET /api/v1/products?platform=ALL + */ + @GetMapping + public List listProducts( + @RequestParam(name = "platform", required = false) String platform, + @RequestParam(name = "partRoles", required = false) List partRoles + ) { + boolean allPlatforms = + (platform == null || platform.isBlank() || platform.equalsIgnoreCase("ALL")); + + List products; + + if (partRoles == null || partRoles.isEmpty()) { + products = allPlatforms + ? productRepository.findAllWithBrand() + : productRepository.findByPlatformWithBrand(platform); + } else { + List roles = partRoles.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + if (roles.isEmpty()) { + products = allPlatforms + ? productRepository.findAllWithBrand() + : productRepository.findByPlatformWithBrand(platform); + } else { + products = allPlatforms + ? productRepository.findByPartRoleInWithBrand(roles) + : productRepository.findByPlatformAndPartRoleInWithBrand(platform, roles); + } + } + + if (products.isEmpty()) return List.of(); + + List productIds = products.stream().map(Product::getId).toList(); + List offers = productOfferRepository.findByProductIdIn(productIds); + + Map> offersByProductId = offers.stream() + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + return products.stream() + .map(p -> { + ProductOffer best = pickBestOffer(offersByProductId.get(p.getId())); + return toProductDto(p, best); + }) + .toList(); + } + + /** + * Product details (v1 contract) + * NOTE: accepts String to avoid MethodArgumentTypeMismatch on /undefined etc. + */ + @GetMapping("/{id}") + public ResponseEntity getProduct(@PathVariable("id") String id) { + Integer productId = parsePositiveInt(id); + if (productId == null) { + return ResponseEntity.badRequest().build(); + } + + Optional productOpt = productRepository.findById(productId); + if (productOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + List offers = productOfferRepository.findByProductId(productId); + ProductOffer best = pickBestOffer(offers); + + return ResponseEntity.ok(toProductDto(productOpt.get(), best)); + } + + private static Integer parsePositiveInt(String raw) { + try { + int n = Integer.parseInt(raw); + return n > 0 ? n : null; + } catch (Exception e) { + return null; + } + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) return null; + + // MVP: lowest effective price wins + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } + + private ProductDto toProductDto(Product p, ProductOffer bestOffer) { + ProductDto dto = new ProductDto(); + + dto.setId(String.valueOf(p.getId())); + dto.setName(p.getName()); + dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null); + dto.setPlatform(p.getPlatform()); + dto.setPartRole(p.getPartRole()); + dto.setCategoryKey(p.getRawCategoryKey()); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + dto.setPrice(price); + + dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null); + + // v1: match what the UI expects today + dto.setImageUrl(p.getMainImageUrl()); + + return dto; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java index c11963f..1ca8df6 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/MerchantFeedImportServiceImpl.java @@ -1,3 +1,11 @@ +// 12/9/25 - This is going to be legacy and will need to be deprecated/deleted +// This is the primary ETL pipeline that ingests merchant product feeds, +// normalizes them, classifies platform/part-role, and upserts products + offers. +// +// IMPORTANT DESIGN NOTES: +// - This importer is authoritative for what ends up in the DB +// - PlatformResolver is applied HERE so unsupported products never leak downstream +// - UI, APIs, and builder assume DB data is already “clean enough” package group.goforward.battlbuilder.services.impl; import group.goforward.battlbuilder.imports.MerchantFeedRow; @@ -10,7 +18,7 @@ import group.goforward.battlbuilder.repos.BrandRepository; import group.goforward.battlbuilder.repos.MerchantRepository; import group.goforward.battlbuilder.repos.ProductOfferRepository; import group.goforward.battlbuilder.repos.ProductRepository; -import group.goforward.battlbuilder.services.CategoryClassificationService; +import group.goforward.battlbuilder.catalog.classification.PlatformResolver; import group.goforward.battlbuilder.services.MerchantFeedImportService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; @@ -30,17 +38,18 @@ import java.time.OffsetDateTime; import java.util.*; /** - * Merchant feed ETL + offer sync. + * MerchantFeedImportServiceImpl * - * - importMerchantFeed: full ETL (products + offers) - * - syncOffersOnly: only refresh offers/prices/stock from an offers feed + * RESPONSIBILITIES: + * - Read merchant product feeds (CSV/TSV/etc) + * - Normalize product data into Product entities + * - Resolve PLATFORM (AR-15, AR-10, NOT-SUPPORTED) + * - Infer part roles (temporary heuristic) + * - Upsert Product + ProductOffer rows * - * 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 + * NON-GOALS: + * - Perfect classification (that’s iterative) + * - UI-level filtering (handled later) */ @Service @Transactional @@ -51,26 +60,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private final MerchantRepository merchantRepository; private final BrandRepository brandRepository; private final ProductRepository productRepository; + // --- Classification --- + // DB-backed rule engine for platform (AR-15 / AR-10 / NOT-SUPPORTED) + private final PlatformResolver platformResolver; private final ProductOfferRepository productOfferRepository; - private final CategoryClassificationService categoryClassificationService; public MerchantFeedImportServiceImpl( MerchantRepository merchantRepository, BrandRepository brandRepository, ProductRepository productRepository, - ProductOfferRepository productOfferRepository, - CategoryClassificationService categoryClassificationService + PlatformResolver platformResolver, + ProductOfferRepository productOfferRepository ) { this.merchantRepository = merchantRepository; this.brandRepository = brandRepository; this.productRepository = productRepository; + this.platformResolver = platformResolver; this.productOfferRepository = productOfferRepository; - this.categoryClassificationService = categoryClassificationService; } - // --------------------------------------------------------------------- - // Full product + offer import - // --------------------------------------------------------------------- + // ========================================================================= + // FULL PRODUCT + OFFER IMPORT + // ========================================================================= + // + // This is the main ETL entry point. + // Triggered via: + // POST /api/admin/imports/{merchantId} + // + // Cache eviction ensures builder/API reads see fresh data. + // @Override @CacheEvict(value = "gunbuilderProducts", allEntries = true) @@ -80,26 +98,52 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService Merchant merchant = merchantRepository.findById(merchantId) .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + // Read & parse CSV feed into structured rows List rows = readFeedRowsForMerchant(merchant); log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); + int processed = 0; + int notSupported = 0; + + // Main ETL loop for (MerchantFeedRow row : rows) { + + // 1) Resolve brand (create if missing) Brand brand = resolveBrand(row); + + // 2) Upsert product + offer Product p = upsertProduct(merchant, brand, row); - log.debug("Upserted product id={}, name='{}', slug={}, platform={}, partRole={}, merchant={}", - p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); + processed++; + + // Metrics: how much we are filtering out + if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { + notSupported++; + } + + // Periodic progress logging + if (processed % 500 == 0) { + log.info("Import progress merchantId={} processed={}/{} notSupportedSoFar={}", + merchantId, processed, rows.size(), notSupported); + } } merchant.setLastFullImportAt(OffsetDateTime.now()); merchantRepository.save(merchant); - log.info("Completed full import for merchantId={} ({} rows processed)", merchantId, rows.size()); + log.info("✅ Completed full import for merchantId={} rows={} processed={} notSupported={}", + merchantId, rows.size(), processed, notSupported); } - // --------------------------------------------------------------------- - // Product upsert - // --------------------------------------------------------------------- + // ========================================================================= + // PRODUCT UPSERT + // ========================================================================= + // + // Strategy: + // - Match existing products by Brand + MPN (preferred) + // - Fallback to Brand + UPC (temporary) + // - Create new product if no match + // private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { String mpn = trimOrNull(row.manufacturerId()); @@ -126,10 +170,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService brand.getName(), mpn, upc, candidates.get(0).getId()); } p = candidates.get(0); - // keep brand stable (but ensure it's set) - if (p.getBrand() == null) p.setBrand(brand); } + // Offers are merchant-specific → always upsert updateProductFromRow(p, merchant, row, isNew); Product saved = productRepository.save(p); @@ -139,13 +182,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return saved; } + // ========================================================================= + // PRODUCT NORMALIZATION + CLASSIFICATION + // ========================================================================= + // + // This is the MOST IMPORTANT method in the file. + // If data looks wrong in the UI, 90% of the time the bug is here. + // private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- - String name = coalesce( + // Prefer productName, fallback to descriptions or SKU + // + String name = coalesce( trimOrNull(row.productName()), trimOrNull(row.shortDescription()), trimOrNull(row.longDescription()), @@ -155,12 +207,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setName(name); // ---------- SLUG ---------- + // Only generate once (unless missing) if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { String baseForSlug = coalesce(trimOrNull(name), trimOrNull(row.sku())); if (baseForSlug == null) baseForSlug = "product-" + System.currentTimeMillis(); String slug = baseForSlug - .toLowerCase(Locale.ROOT) + .toLowerCase() .replaceAll("[^a-z0-9]+", "-") .replaceAll("(^-|-$)", ""); @@ -186,26 +239,55 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService p.setMpn(mpn); p.setUpc(null); // placeholder - // ---------- CLASSIFICATION (rawCategoryKey + platform + partRole) ---------- - CategoryClassificationService.Result r = categoryClassificationService.classify(merchant, row); + // ---------- RAW CATEGORY KEY ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); - // Always persist the rawCategoryKey coming out of classification (consistent keying) - p.setRawCategoryKey(r.rawCategoryKey()); + // ---------- PLATFORM RESOLUTION ---------- + // + // ORDER OF OPERATIONS: + // 1) Base heuristic (string contains AR-15, AR-10, etc) + // 2) PlatformResolver DB rules (can override to NOT-SUPPORTED) + // + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String basePlatform = inferPlatform(row); - // Respect platformLocked: if locked and platform already present, keep it. - if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked()) || p.getPlatform() == null || p.getPlatform().isBlank()) { - String platform = (r.platform() == null || r.platform().isBlank()) ? "AR-15" : r.platform(); - p.setPlatform(platform); + Long mId = merchant.getId() == null ? null : merchant.getId().longValue(); + Long bId = (p.getBrand() != null && p.getBrand().getId() != null) ? p.getBrand().getId().longValue() : null; + + // DB rules can force NOT-SUPPORTED (or AR-10, etc.) + String resolvedPlatform = platformResolver.resolve( + mId, + bId, + p.getName(), + rawCategoryKey + ); + + String finalPlatform = resolvedPlatform != null + ? resolvedPlatform + : (basePlatform != null ? basePlatform : "AR-15"); + + p.setPlatform(finalPlatform); } - // Part role should always be driven by classification (mapping/rules/inference), - // but if something returns null/blank, treat as unknown. - String partRole = (r.partRole() == null) ? "unknown" : r.partRole().trim(); - if (partRole.isBlank()) partRole = "unknown"; + // ---------- PART ROLE (TEMPORARY) ---------- + // This is intentionally weak — PartRoleResolver + mappings improve this later + String partRole = inferPartRole(row); + if (partRole == null || partRole.isBlank()) { + partRole = "UNKNOWN"; + } else { + partRole = partRole.trim(); + } p.setPartRole(partRole); // ---------- IMPORT STATUS ---------- - if ("unknown".equalsIgnoreCase(partRole) || "UNKNOWN".equalsIgnoreCase(partRole)) { + // If platform is NOT-SUPPORTED, force PENDING so it's easy to audit. + if (PlatformResolver.NOT_SUPPORTED.equalsIgnoreCase(p.getPlatform())) { + p.setImportStatus(ImportStatus.PENDING_MAPPING); + return; + } + + if ("UNKNOWN".equalsIgnoreCase(partRole)) { p.setImportStatus(ImportStatus.PENDING_MAPPING); } else { p.setImportStatus(ImportStatus.MAPPED); @@ -303,15 +385,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService merchant.setLastOfferSyncAt(OffsetDateTime.now()); merchantRepository.save(merchant); - log.info("Completed offers-only sync for merchantId={} ({} rows processed)", + log.info("✅ Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); } private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { String avantlinkProductId = trimOrNull(row.get("SKU")); - if (avantlinkProductId == null || avantlinkProductId.isBlank()) { - return; - } + if (avantlinkProductId == null || avantlinkProductId.isBlank()) return; ProductOffer offer = productOfferRepository .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) @@ -348,7 +428,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService if (lower.contains("false") || lower.contains("no") || lower.contains("0") || lower.contains("out of stock")) { return Boolean.FALSE; } - return Boolean.FALSE; } @@ -397,9 +476,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } private CSVFormat detectCsvFormat(String feedUrl) throws Exception { - // Try a few common delimiters, but only require the SKU header to be present. char[] delimiters = new char[]{'\t', ',', ';', '|'}; - List requiredHeaders = Collections.singletonList("SKU"); + List requiredHeaders = Arrays.asList("SKU"); Exception lastException = null; @@ -436,10 +514,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } 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() .setDelimiter(',') .setHeader() @@ -541,7 +615,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService private String getCsvValue(CSVRecord rec, String header) { if (rec == null || header == null) return null; if (!rec.isMapped(header)) return null; - try { return rec.get(header); } catch (IllegalArgumentException ex) { @@ -574,4 +647,83 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService } return candidate; } + + private String buildRawCategoryKey(MerchantFeedRow row) { + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + + List parts = new ArrayList<>(); + if (dept != null) parts.add(dept); + if (cat != null) parts.add(cat); + if (sub != null) parts.add(sub); + + return parts.isEmpty() ? null : String.join(" > ", parts); + } + + private String inferPlatform(MerchantFeedRow row) { + // Use *all* category signals. Many feeds put AR-10/AR-15 in SubCategory. + String blob = String.join(" ", + coalesce(trimOrNull(row.department()), ""), + coalesce(trimOrNull(row.category()), ""), + coalesce(trimOrNull(row.subCategory()), "") + ).toLowerCase(Locale.ROOT); + + if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15"; + if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10"; + if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9"; + if (blob.contains("ak-47") || blob.contains("ak47") || blob.contains("ak ")) return "AK-47"; + + return "AR-15"; // safe default + } + + private String inferPartRole(MerchantFeedRow row) { + // Use more than just subCategory; complete uppers were being mislabeled previously. + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + String name = trimOrNull(row.productName()); + + String rawCategoryKey = buildRawCategoryKey(row); + + String combined = String.join(" ", + coalesce(rawCategoryKey, ""), + coalesce(dept, ""), + coalesce(cat, ""), + coalesce(sub, ""), + coalesce(name, "") + ).toLowerCase(Locale.ROOT); + + // ---- High priority: complete assemblies ---- + if (combined.contains("complete upper") || + combined.contains("complete uppers") || + combined.contains("upper receiver assembly") || + combined.contains("barreled upper")) { + return "complete-upper"; + } + + if (combined.contains("complete lower") || + combined.contains("complete lowers") || + combined.contains("lower receiver assembly")) { + return "complete-lower"; + } + + // ---- Receivers ---- + if (combined.contains("stripped upper")) return "upper-receiver"; + if (combined.contains("stripped lower")) return "lower-receiver"; + + // ---- Common parts ---- + if (combined.contains("handguard") || combined.contains("rail")) return "handguard"; + if (combined.contains("barrel")) return "barrel"; + if (combined.contains("gas block") || combined.contains("gasblock")) return "gas-block"; + if (combined.contains("gas tube") || combined.contains("gastube")) return "gas-tube"; + if (combined.contains("charging handle")) return "charging-handle"; + if (combined.contains("bolt carrier") || combined.contains(" bcg")) return "bcg"; + if (combined.contains("magazine") || combined.contains(" mag ")) return "magazine"; + if (combined.contains("stock") || combined.contains("buttstock") || combined.contains("brace")) return "stock"; + if (combined.contains("pistol grip") || combined.contains(" grip")) return "grip"; + if (combined.contains("trigger")) return "trigger"; + + return "unknown"; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/ProductDetailsDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDetailsDto.java new file mode 100644 index 0000000..cafc85c --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDetailsDto.java @@ -0,0 +1,53 @@ +package group.goforward.battlbuilder.web.dto; + +import java.math.BigDecimal; +import java.util.List; + +public class ProductDetailsDto { + private String id; + private String name; + private String brand; + private String platform; + private String partRole; + private String categoryKey; + private String imageUrl; + + // “Best” offer snapshot (what list uses today) + private BigDecimal price; + private String buyUrl; + + // Full offer list for the details page + private List offers; + + public ProductDetailsDto() {} + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + + 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 String getCategoryKey() { return categoryKey; } + public void setCategoryKey(String categoryKey) { this.categoryKey = categoryKey; } + + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public BigDecimal getPrice() { return price; } + public void setPrice(BigDecimal price) { this.price = price; } + + public String getBuyUrl() { return buyUrl; } + public void setBuyUrl(String buyUrl) { this.buyUrl = buyUrl; } + + public List getOffers() { return offers; } + public void setOffers(List offers) { this.offers = offers; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java index 1539b81..74838cd 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/ProductDto.java @@ -1,4 +1,3 @@ -// src/main/java/com/ballistic/gunbuilder/api/dto/GunbuilderProductDto.java package group.goforward.battlbuilder.web.dto; import java.math.BigDecimal; @@ -9,15 +8,35 @@ public class ProductDto { private String brand; private String platform; private String partRole; + private String categoryKey; private BigDecimal price; - private String imageUrl; private String buyUrl; + private String imageUrl; - public String getId() { - return id; - } + public String getId() { return id; } + public void setId(String id) { this.id = id; } - public void setId(String id) { - this.id = id; - } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + + 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 String getCategoryKey() { return categoryKey; } + public void setCategoryKey(String categoryKey) { this.categoryKey = categoryKey; } + + public BigDecimal getPrice() { return price; } + public void setPrice(BigDecimal price) { this.price = price; } + + public String getBuyUrl() { return buyUrl; } + public void setBuyUrl(String buyUrl) { this.buyUrl = buyUrl; } + + public String getImageUrl() { return imageUrl; } + public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } } \ No newline at end of file