allow for category mapping from GB UI. Add platform locked flag to products.

This commit is contained in:
2025-12-01 07:54:05 -05:00
parent 7166b92d32
commit 0f5978fd11
13 changed files with 427 additions and 318 deletions

View File

@@ -0,0 +1,24 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.repos.MerchantRepository;
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/admin/merchants")
@CrossOrigin // adjust later if you want
public class AdminMerchantController {
private final MerchantRepository merchantRepository;
public AdminMerchantController(MerchantRepository merchantRepository) {
this.merchantRepository = merchantRepository;
}
@GetMapping
public List<Merchant> getMerchants() {
// If you want a DTO here, you can wrap it, but this is fine for internal admin
return merchantRepository.findAll();
}
}

View File

@@ -0,0 +1,65 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.service.MerchantCategoryMappingService;
import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto;
import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/admin/merchant-category-mappings")
@CrossOrigin
public class MerchantCategoryMappingController {
private final MerchantCategoryMappingService mappingService;
private final MerchantRepository merchantRepository;
public MerchantCategoryMappingController(
MerchantCategoryMappingService mappingService,
MerchantRepository merchantRepository
) {
this.mappingService = mappingService;
this.merchantRepository = merchantRepository;
}
@GetMapping
public List<MerchantCategoryMappingDto> listMappings(
@RequestParam("merchantId") Integer merchantId
) {
List<MerchantCategoryMapping> mappings = mappingService.findByMerchant(merchantId);
return mappings.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
@PostMapping
public MerchantCategoryMappingDto upsertMapping(
@RequestBody UpsertMerchantCategoryMappingRequest request
) {
Merchant merchant = merchantRepository
.findById(request.getMerchantId())
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId()));
MerchantCategoryMapping mapping = mappingService.upsertMapping(
merchant,
request.getRawCategory(),
request.getMappedPartRole()
);
return toDto(mapping);
}
private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) {
MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto();
dto.setId(mapping.getId());
dto.setMerchantId(mapping.getMerchant().getId());
dto.setMerchantName(mapping.getMerchant().getName());
dto.setRawCategory(mapping.getRawCategory());
dto.setMappedPartRole(mapping.getMappedPartRole());
return dto;
}
}

View File

@@ -19,8 +19,8 @@ import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.BrandRepository;
import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.repos.MerchantCategoryMapRepository; import group.goforward.ballistic.service.MerchantCategoryMappingService;
import group.goforward.ballistic.model.MerchantCategoryMap; import group.goforward.ballistic.service.MerchantCategoryMappingService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import group.goforward.ballistic.repos.ProductOfferRepository; import group.goforward.ballistic.repos.ProductOfferRepository;
@@ -35,18 +35,18 @@ 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 MerchantCategoryMapRepository merchantCategoryMapRepository; private final MerchantCategoryMappingService merchantCategoryMappingService;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
BrandRepository brandRepository, BrandRepository brandRepository,
ProductRepository productRepository, ProductRepository productRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository, MerchantCategoryMappingService merchantCategoryMappingService,
ProductOfferRepository productOfferRepository) { ProductOfferRepository productOfferRepository) {
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
this.brandRepository = brandRepository; this.brandRepository = brandRepository;
this.productRepository = productRepository; this.productRepository = productRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository; this.merchantCategoryMappingService = merchantCategoryMappingService;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
@@ -180,8 +180,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setUpc(null); p.setUpc(null);
// ---------- PLATFORM ---------- // ---------- PLATFORM ----------
String platform = inferPlatform(row); if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
p.setPlatform(platform != null ? platform : "AR-15"); String platform = inferPlatform(row);
p.setPlatform(platform != null ? platform : "AR-15");
}
// ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- // ---------- RAW CATEGORY KEY (for debugging / analytics) ----------
String rawCategoryKey = buildRawCategoryKey(row); String rawCategoryKey = buildRawCategoryKey(row);
@@ -204,13 +206,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return; return;
} }
// Simple approach: always create a new offer row. // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id
// (If you want idempotent imports later, we can add a repository finder ProductOffer offer = productOfferRepository
// like findByProductAndMerchantAndAvantlinkProductId(...) and reuse the row.) .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
ProductOffer offer = new ProductOffer(); .orElseGet(ProductOffer::new);
offer.setProduct(product);
offer.setMerchant(merchant); // If this is a brandnew offer, initialize key fields
offer.setAvantlinkProductId(avantlinkProductId); if (offer.getId() == null) {
offer.setMerchant(merchant);
offer.setProduct(product);
offer.setAvantlinkProductId(avantlinkProductId);
offer.setFirstSeenAt(OffsetDateTime.now());
} else {
// Make sure associations stay in sync if anything changed
offer.setMerchant(merchant);
offer.setProduct(product);
}
// Identifiers // Identifiers
offer.setSku(trimOrNull(row.sku())); offer.setSku(trimOrNull(row.sku()));
@@ -227,12 +238,14 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
BigDecimal effectivePrice; BigDecimal effectivePrice;
BigDecimal originalPrice; BigDecimal originalPrice;
if (sale != null) { // Prefer sale price if it exists and is less than or equal to retail
if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) {
effectivePrice = sale; effectivePrice = sale;
originalPrice = (retail != null ? retail : sale); originalPrice = (retail != null ? retail : sale);
} else { } else {
effectivePrice = retail; // Otherwise fall back to retail or whatever is present
originalPrice = retail; effectivePrice = (retail != null ? retail : sale);
originalPrice = (retail != null ? retail : sale);
} }
offer.setPrice(effectivePrice); offer.setPrice(effectivePrice);
@@ -243,10 +256,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
// We don't have a real stock flag in this CSV, so assume in-stock for now // We don't have a real stock flag in this CSV, so assume in-stock for now
offer.setInStock(Boolean.TRUE); offer.setInStock(Boolean.TRUE);
// Timestamps // Update "last seen" on every import pass
OffsetDateTime now = OffsetDateTime.now(); offer.setLastSeenAt(OffsetDateTime.now());
offer.setLastSeenAt(now);
offer.setFirstSeenAt(now); // first import: treat now as first seen
productOfferRepository.save(offer); productOfferRepository.save(offer);
} }
@@ -256,15 +267,13 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
String rawCategoryKey = buildRawCategoryKey(row); String rawCategoryKey = buildRawCategoryKey(row);
if (rawCategoryKey != null) { if (rawCategoryKey != null) {
MerchantCategoryMap mapping = merchantCategoryMapRepository // Delegate to the mapping service, which will:
.findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategoryKey) // - Look up an existing mapping
.orElse(null); // - If none exists, create a placeholder row with null mappedPartRole
// - Return the mapped partRole, or null if not yet mapped
if (mapping != null && mapping.isEnabled()) { String mapped = merchantCategoryMappingService.resolvePartRole(merchant, rawCategoryKey);
String mappedPartRole = trimOrNull(mapping.getPartRole()); if (mapped != null && !mapped.isBlank()) {
if (mappedPartRole != null && !mappedPartRole.isBlank()) { return mapped;
return mappedPartRole;
}
} }
} }
@@ -274,7 +283,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return keywordRole; return keywordRole;
} }
// Last resort: log as unmapped and return null/unknown // Last resort: log as unmapped and return null
System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName() System.out.println("IMPORT !!! UNMAPPED CATEGORY for merchant=" + merchant.getName()
+ ", rawCategoryKey='" + rawCategoryKey + "'" + ", rawCategoryKey='" + rawCategoryKey + "'"
+ ", sku=" + row.sku() + ", sku=" + row.sku()

View File

@@ -1,86 +0,0 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
@Entity
@Table(name = "merchant_category_map")
public class MerchantCategoryMap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
@Column(name = "raw_category", nullable = false, length = 255)
private String rawCategory;
// NEW FIELDS
@Column(name = "platform")
private String platform; // e.g. "AR-15", "AR-10"
@Column(name = "part_role")
private String partRole; // e.g. "barrel", "handguard"
@Column(name = "canonical_category")
private String canonicalCategory; // e.g. "Rifle Barrels"
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
// --- getters & setters ---
public Integer getId() {
return id;
}
public Merchant getMerchant() {
return merchant;
}
public void setMerchant(Merchant merchant) {
this.merchant = merchant;
}
public String getRawCategory() {
return rawCategory;
}
public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory;
}
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 getCanonicalCategory() {
return canonicalCategory;
}
public void setCanonicalCategory(String canonicalCategory) {
this.canonicalCategory = canonicalCategory;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,91 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(
name = "merchant_category_mappings",
uniqueConstraints = @UniqueConstraint(
name = "uq_merchant_category",
columnNames = { "merchant_id", "raw_category" }
)
)
public class MerchantCategoryMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL
@Column(name = "id", nullable = false)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
@Column(name = "raw_category", nullable = false, length = 512)
private String rawCategory;
@Column(name = "mapped_part_role", length = 128)
private String mappedPartRole; // e.g. "upper-receiver", "barrel"
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now();
@PreUpdate
public void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
// getters & setters
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Merchant getMerchant() {
return merchant;
}
public void setMerchant(Merchant merchant) {
this.merchant = merchant;
}
public String getRawCategory() {
return rawCategory;
}
public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory;
}
public String getMappedPartRole() {
return mappedPartRole;
}
public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole;
}
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;
}
}

View File

@@ -59,6 +59,11 @@ public class Product {
@Column(name = "raw_category_key") @Column(name = "raw_category_key")
private String rawCategoryKey; private String rawCategoryKey;
@Column(name = "platform_locked", nullable = false)
private Boolean platformLocked = false;
// --- lifecycle hooks --- // --- lifecycle hooks ---
@PrePersist @PrePersist
@@ -209,4 +214,13 @@ public class Product {
public void setDeletedAt(Instant deletedAt) { public void setDeletedAt(Instant deletedAt) {
this.deletedAt = deletedAt; this.deletedAt = deletedAt;
} }
public Boolean getPlatformLocked() {
return platformLocked;
}
public void setPlatformLocked(Boolean platformLocked) {
this.platformLocked = platformLocked;
}
} }

View File

@@ -1,12 +0,0 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
Optional<MerchantCategoryMap> findByMerchantAndRawCategoryIgnoreCase(Merchant merchant, String rawCategory);
}

View File

@@ -0,0 +1,17 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MerchantCategoryMappingRepository
extends JpaRepository<MerchantCategoryMapping, Integer> {
Optional<MerchantCategoryMapping> findByMerchantIdAndRawCategoryIgnoreCase(
Integer merchantId,
String rawCategory
);
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
}

View File

@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> { public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> {
@@ -12,4 +13,10 @@ public interface ProductOfferRepository extends JpaRepository<ProductOffer, Inte
// Used by the /api/products/gunbuilder endpoint // Used by the /api/products/gunbuilder endpoint
List<ProductOffer> findByProductIdIn(Collection<Integer> productIds); List<ProductOffer> findByProductIdIn(Collection<Integer> productIds);
// Unique offer lookup for importer upsert
Optional<ProductOffer> findByMerchantIdAndAvantlinkProductId(
Integer merchantId,
String avantlinkProductId
);
} }

View File

@@ -1,179 +0,0 @@
package group.goforward.ballistic.seed;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMap;
import group.goforward.ballistic.repos.MerchantCategoryMapRepository;
import group.goforward.ballistic.repos.MerchantRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MerchantCategoryMapSeeder {
@Bean
public CommandLineRunner seedMerchantCategoryMaps(MerchantRepository merchantRepository,
MerchantCategoryMapRepository mapRepository) {
return args -> {
// --- Guard: only seed if table is (mostly) empty ---
long existing = mapRepository.count();
if (existing > 0) {
System.out.println("CategoryMapSeeder: found " + existing + " existing mappings, skipping seeding.");
return;
}
System.out.println("CategoryMapSeeder: seeding initial MerchantCategoryMap rows...");
// Adjust merchant names if they differ in your DB
seedAeroPrecision(merchantRepository, mapRepository);
seedBrownells(merchantRepository, mapRepository);
seedPSA(merchantRepository, mapRepository);
System.out.println("CategoryMapSeeder: seeding complete.");
};
}
// ---------------------------------------------------------------------
// AERO PRECISION
// ---------------------------------------------------------------------
private void seedAeroPrecision(MerchantRepository merchantRepository,
MerchantCategoryMapRepository mapRepository) {
merchantRepository.findByNameIgnoreCase("Aero Precision").ifPresent(merchant -> {
// Keys come from Department | Category | SubCategory combos
upsert(merchant, "Charging Handles",
"AR-15", "charging-handle", "Charging Handles", true, mapRepository);
upsert(merchant, "Shop All Barrels",
null, "barrel", "Rifle Barrels", true, mapRepository);
upsert(merchant, "Lower Parts Kits",
"AR-15", "lower-parts-kit", "Lower Parts Kits", true, mapRepository);
upsert(merchant, "Handguards",
"AR-15", "handguard", "Handguards & Rails", true, mapRepository);
upsert(merchant, "Upper Receivers",
"AR-15", "upper-receiver", "Upper Receivers", true, mapRepository);
// Platform-only hints (let your existing heuristics decide part_role)
upsert(merchant, ".308 Winchester",
"AR-10", null, "AR-10 / .308 Parts", true, mapRepository);
upsert(merchant, "6.5 Creedmoor",
"AR-10", null, "6.5 Creedmoor Parts", true, mapRepository);
upsert(merchant, "5.56 Nato / .223 Wylde",
"AR-15", null, "5.56 / .223 Wylde Parts", true, mapRepository);
});
}
// ---------------------------------------------------------------------
// BROWNELLS
// ---------------------------------------------------------------------
private void seedBrownells(MerchantRepository merchantRepository,
MerchantCategoryMapRepository mapRepository) {
merchantRepository.findByNameIgnoreCase("Brownells").ifPresent(merchant -> {
upsert(merchant, "Rifle Parts | Receiver Parts | Receivers",
null, "receiver", "Rifle Receivers", true, mapRepository);
upsert(merchant, "Rifle Parts | Barrel Parts | Rifle Barrels",
null, "barrel", "Rifle Barrels", true, mapRepository);
upsert(merchant, "Rifle Parts | Stock Parts | Rifle Stocks",
null, "stock", "Rifle Stocks", true, mapRepository);
upsert(merchant, "Rifle Parts | Muzzle Devices | Compensators & Muzzle Brakes",
null, "muzzle-device", "Muzzle Devices", true, mapRepository);
upsert(merchant, "Rifle Parts | Trigger Parts | Triggers",
null, "trigger", "Triggers", true, mapRepository);
upsert(merchant, "Rifle Parts | Receiver Parts | Magazine Parts",
null, "magazine", "Magazine & Mag Parts", true, mapRepository);
upsert(merchant, "Rifle Parts | Sights | Front Sights",
null, "sight", "Iron Sights", true, mapRepository);
upsert(merchant, "Rifle Parts | Sights | Rear Sights",
null, "sight", "Iron Sights", true, mapRepository);
upsert(merchant, "Rifle Parts | Receiver Parts | Buffer Tube Parts",
null, "buffer-tube", "Buffer Tubes & Parts", true, mapRepository);
upsert(merchant, "Rifle Parts | Stock Parts | Buttstocks",
null, "stock", "Buttstocks", true, mapRepository);
});
}
// ---------------------------------------------------------------------
// PALMETTO STATE ARMORY (PSA)
// ---------------------------------------------------------------------
private void seedPSA(MerchantRepository merchantRepository,
MerchantCategoryMapRepository mapRepository) {
merchantRepository.findByNameIgnoreCase("Palmetto State Armory").ifPresent(merchant -> {
upsert(merchant, "AR-15 Parts | Upper Parts | Stripped Uppers",
"AR-15", "upper-receiver", "AR-15 Stripped Uppers", true, mapRepository);
upsert(merchant, "AR-15 Parts | Upper Parts | Complete Uppers",
"AR-15", "complete-upper", "AR-15 Complete Uppers", true, mapRepository);
upsert(merchant, "AR-15 Parts | Barrel Parts | Barrels",
"AR-15", "barrel", "AR-15 Barrels", true, mapRepository);
upsert(merchant, "AR-15 Parts | Lower Parts | Stripped Lowers",
"AR-15", "lower-receiver", "AR-15 Stripped Lowers", true, mapRepository);
upsert(merchant, "AR-15 Parts | Handguard Parts | Handguards",
"AR-15", "handguard", "AR-15 Handguards", true, mapRepository);
upsert(merchant, "AR-15 Parts | Bolt Carrier Groups | Bolt Carrier Groups",
"AR-15", "bcg", "AR-15 BCGs", true, mapRepository);
upsert(merchant, "AR-15 Parts | Trigger Parts | Triggers",
"AR-15", "trigger", "AR-15 Triggers", true, mapRepository);
upsert(merchant, "AR-15 Parts | Stock Parts | Stocks",
"AR-15", "stock", "AR-15 Stocks", true, mapRepository);
upsert(merchant, "AR-15 Parts | Muzzle Devices | Muzzle Devices",
"AR-15", "muzzle-device", "AR-15 Muzzle Devices", true, mapRepository);
});
}
// ---------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------
private void upsert(Merchant merchant,
String rawCategory,
String platform,
String partRole,
String canonicalCategory,
boolean enabled,
MerchantCategoryMapRepository mapRepository) {
MerchantCategoryMap map = mapRepository
.findByMerchantAndRawCategoryIgnoreCase(merchant, rawCategory)
.orElseGet(MerchantCategoryMap::new);
map.setMerchant(merchant);
map.setRawCategory(rawCategory);
// These fields are optional null means “let heuristics or defaults handle it”
map.setPlatform(platform);
map.setPartRole(partRole);
map.setCanonicalCategory(canonicalCategory);
map.setEnabled(enabled);
mapRepository.save(map);
}
}

View File

@@ -0,0 +1,77 @@
package group.goforward.ballistic.service;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class MerchantCategoryMappingService {
private final MerchantCategoryMappingRepository mappingRepository;
public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) {
this.mappingRepository = mappingRepository;
}
public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) {
return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId);
}
/**
* Resolve a partRole for a given raw category.
* If not found, create a row with null mappedPartRole and return null (so importer can skip).
*/
@Transactional
public String resolvePartRole(Merchant merchant, String rawCategory) {
if (rawCategory == null || rawCategory.isBlank()) {
return null;
}
String trimmed = rawCategory.trim();
Optional<MerchantCategoryMapping> existingOpt =
mappingRepository.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed);
if (existingOpt.isPresent()) {
return existingOpt.get().getMappedPartRole();
}
// Create placeholder row
MerchantCategoryMapping mapping = new MerchantCategoryMapping();
mapping.setMerchant(merchant);
mapping.setRawCategory(trimmed);
mapping.setMappedPartRole(null);
mappingRepository.save(mapping);
// No mapping yet → importer should skip this product
return null;
}
/**
* Upsert mapping (admin UI).
*/
@Transactional
public MerchantCategoryMapping upsertMapping(Merchant merchant, String rawCategory, String mappedPartRole) {
String trimmed = rawCategory.trim();
MerchantCategoryMapping mapping = mappingRepository
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> {
MerchantCategoryMapping m = new MerchantCategoryMapping();
m.setMerchant(merchant);
m.setRawCategory(trimmed);
return m;
});
mapping.setMappedPartRole(
(mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
);
return mappingRepository.save(mapping);
}
}

View File

@@ -0,0 +1,50 @@
package group.goforward.ballistic.web.dto;
public class MerchantCategoryMappingDto {
private Integer id;
private Integer merchantId;
private String merchantName;
private String rawCategory;
private String mappedPartRole;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getMerchantId() {
return merchantId;
}
public void setMerchantId(Integer merchantId) {
this.merchantId = merchantId;
}
public String getMerchantName() {
return merchantName;
}
public void setMerchantName(String merchantName) {
this.merchantName = merchantName;
}
public String getRawCategory() {
return rawCategory;
}
public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory;
}
public String getMappedPartRole() {
return mappedPartRole;
}
public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole;
}
}

View File

@@ -0,0 +1,32 @@
package group.goforward.ballistic.web.dto;
public class UpsertMerchantCategoryMappingRequest {
private Integer merchantId;
private String rawCategory;
private String mappedPartRole; // can be null to "unmap"
public Integer getMerchantId() {
return merchantId;
}
public void setMerchantId(Integer merchantId) {
this.merchantId = merchantId;
}
public String getRawCategory() {
return rawCategory;
}
public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory;
}
public String getMappedPartRole() {
return mappedPartRole;
}
public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole;
}
}