Compare commits

...

2 Commits

Author SHA1 Message Date
c4d2adad1a running build. New merchant import admin page. 2025-12-01 21:28:39 -05:00
0b2b3afd0c adding more package-info dfiles 2025-12-01 16:58:49 -05:00
10 changed files with 347 additions and 35 deletions

View File

@@ -1,24 +0,0 @@
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

@@ -6,17 +6,34 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/admin/imports")
@CrossOrigin(origins = "http://localhost:3000")
public class ImportController {
private final MerchantFeedImportService importService;
private final MerchantFeedImportService merchantFeedImportService;
public ImportController(MerchantFeedImportService importService) {
this.importService = importService;
public ImportController(MerchantFeedImportService merchantFeedImportService) {
this.merchantFeedImportService = merchantFeedImportService;
}
/**
* Full product + offer import for a merchant.
*
* POST /admin/imports/{merchantId}
*/
@PostMapping("/{merchantId}")
public ResponseEntity<Void> importMerchant(@PathVariable Integer merchantId) {
importService.importMerchantFeed(merchantId);
return ResponseEntity.accepted().build();
merchantFeedImportService.importMerchantFeed(merchantId);
return ResponseEntity.noContent().build();
}
/**
* Offers-only sync (price/stock) for a merchant.
*
* POST /admin/imports/{merchantId}/offers-only
*/
@PostMapping("/{merchantId}/offers-only")
public ResponseEntity<Void> syncOffersOnly(@PathVariable Integer merchantId) {
merchantFeedImportService.syncOffersOnly(merchantId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,63 @@
// MerchantAdminController.java
package group.goforward.ballistic.controllers;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.web.dto.MerchantAdminDto;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.List;
@RestController
@RequestMapping("/admin/merchants")
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
public class MerchantAdminController {
private final MerchantRepository merchantRepository;
public MerchantAdminController(MerchantRepository merchantRepository) {
this.merchantRepository = merchantRepository;
}
@GetMapping
public List<MerchantAdminDto> listMerchants() {
return merchantRepository.findAll().stream().map(this::toDto).toList();
}
@PutMapping("/{id}")
public MerchantAdminDto updateMerchant(
@PathVariable Integer id,
@RequestBody MerchantAdminDto payload
) {
Merchant merchant = merchantRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Merchant not found"));
merchant.setFeedUrl(payload.getFeedUrl());
merchant.setOfferFeedUrl(payload.getOfferFeedUrl());
merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true);
// dont touch last* here; those are set by import jobs
merchant = merchantRepository.save(merchant);
return toDto(merchant);
}
private MerchantAdminDto toDto(Merchant m) {
MerchantAdminDto dto = new MerchantAdminDto();
dto.setId(m.getId());
dto.setName(m.getName());
dto.setFeedUrl(m.getFeedUrl());
dto.setOfferFeedUrl(m.getOfferFeedUrl());
dto.setIsActive(m.getIsActive());
dto.setLastFullImportAt(m.getLastFullImportAt());
dto.setLastOfferSyncAt(m.getLastOfferSyncAt());
return dto;
}
}

View File

@@ -1 +1,13 @@
/**
* Provides the classes necessary for the Spring Controllers for the ballistic -Builder application.
* This package includes Controllers for Spring-Boot application
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg
* @version 1.1
*/
package group.goforward.ballistic.controllers;

View File

@@ -1 +1,13 @@
/**
* Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application.
* This package includes DTO for Spring-Boot application
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
*
* @since 1.0
* @author Sean Strawsburg
* @version 1.1
*/
package group.goforward.ballistic.imports.dto;

View File

@@ -8,6 +8,7 @@ import java.time.OffsetDateTime;
@Entity
@Table(name = "merchants")
public class Merchant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
@@ -22,9 +23,18 @@ public class Merchant {
@Column(name = "feed_url", nullable = false, length = Integer.MAX_VALUE)
private String feedUrl;
@Column(name = "offer_feed_url")
private String offerFeedUrl;
@Column(name = "last_full_import_at")
private OffsetDateTime lastFullImportAt;
@Column(name = "last_offer_sync_at")
private OffsetDateTime lastOfferSyncAt;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive = false;
private Boolean isActive = true;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
@@ -34,6 +44,10 @@ public class Merchant {
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// -----------------------
// GETTERS & SETTERS
// -----------------------
public Integer getId() {
return id;
}
@@ -66,12 +80,36 @@ public class Merchant {
this.feedUrl = feedUrl;
}
public String getOfferFeedUrl() {
return offerFeedUrl;
}
public void setOfferFeedUrl(String offerFeedUrl) {
this.offerFeedUrl = offerFeedUrl;
}
public OffsetDateTime getLastFullImportAt() {
return lastFullImportAt;
}
public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
this.lastFullImportAt = lastFullImportAt;
}
public OffsetDateTime getLastOfferSyncAt() {
return lastOfferSyncAt;
}
public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
this.lastOfferSyncAt = lastOfferSyncAt;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
public void setIsActive(Boolean active) {
this.isActive = active;
}
public OffsetDateTime getCreatedAt() {
@@ -89,5 +127,4 @@ public class Merchant {
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -3,7 +3,12 @@ package group.goforward.ballistic.services;
public interface MerchantFeedImportService {
/**
* Import the feed for a given merchant id.
* Full product + offer import for a given merchant.
*/
void importMerchantFeed(Integer merchantId);
/**
* Offers-only sync (price / stock) for a given merchant.
*/
void syncOffersOnly(Integer merchantId);
}

View File

@@ -4,6 +4,8 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.io.Reader;
import java.io.InputStreamReader;
import java.net.URL;
@@ -123,6 +125,37 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return saved;
}
private List<Map<String, String>> fetchFeedRows(String feedUrl) {
System.out.println("OFFERS >>> reading offer feed from: " + feedUrl);
List<Map<String, String>> rows = new ArrayList<>();
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
: java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8);
CSVParser parser = CSVFormat.DEFAULT
.withFirstRecordAsHeader()
.withIgnoreSurroundingSpaces()
.withTrim()
.parse(reader)) {
// capture header names from the CSV
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) {
Map<String, String> row = new HashMap<>();
for (String header : headers) {
row.put(header, rec.get(header));
}
rows.add(row);
}
} catch (Exception ex) {
throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex);
}
System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows");
return rows;
}
private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) {
// ---------- NAME ----------
@@ -465,4 +498,79 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return "unknown";
}
public void syncOffersOnly(Integer merchantId) {
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new RuntimeException("Merchant not found"));
if (Boolean.FALSE.equals(merchant.getIsActive())) {
return;
}
// Use offerFeedUrl if present, else fall back to feedUrl
String feedUrl = merchant.getOfferFeedUrl() != null
? merchant.getOfferFeedUrl()
: merchant.getFeedUrl();
if (feedUrl == null) {
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
}
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
for (Map<String, String> row : rows) {
upsertOfferOnlyFromRow(merchant, row);
}
merchant.setLastOfferSyncAt(OffsetDateTime.now());
merchantRepository.save(merchant);
}
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
// For the offer-only sync, we key offers by the same identifier we used when creating them.
// In the current AvantLink-style feed, that is the SKU column.
String avantlinkProductId = trimOrNull(row.get("SKU"));
if (avantlinkProductId == null || avantlinkProductId.isBlank()) {
return;
}
// Find existing offer
ProductOffer offer = productOfferRepository
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
.orElse(null);
if (offer == null) {
// This is a *sync* pass, not full ETL if we don't already have an offer, skip.
return;
}
// Parse price fields (column names match the main product feed)
BigDecimal price = parseBigDecimal(row.get("Sale Price"));
BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price"));
// Update only *offer* fields do not touch Product
offer.setPrice(price);
offer.setOriginalPrice(originalPrice);
offer.setInStock(parseInStock(row));
// Prefer a fresh Buy Link from the feed if present, otherwise keep existing
String newBuyUrl = trimOrNull(row.get("Buy Link"));
offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl()));
offer.setLastSeenAt(OffsetDateTime.now());
productOfferRepository.save(offer);
}
private Boolean parseInStock(Map<String, String> row) {
String inStock = trimOrNull(row.get("In Stock"));
if (inStock == null) return Boolean.FALSE;
String lower = inStock.toLowerCase();
if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) {
return Boolean.TRUE;
}
if (lower.contains("false") || lower.contains("no") || lower.contains("0")) {
return Boolean.FALSE;
}
return Boolean.FALSE;
}
}

View File

@@ -1 +1,13 @@
/**
* Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application.
* This package includes Services implementations for Spring-Boot application
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg
* @version 1.1
*/
package group.goforward.ballistic.services.impl;

View File

@@ -0,0 +1,70 @@
// MerchantAdminDto.java
package group.goforward.ballistic.web.dto;
import java.time.OffsetDateTime;
public class MerchantAdminDto {
private Integer id;
private String name;
private String feedUrl;
private String offerFeedUrl;
private Boolean isActive;
private OffsetDateTime lastFullImportAt;
private OffsetDateTime lastOfferSyncAt;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getFeedUrl() {
return feedUrl;
}
public void setFeedUrl(String feedUrl) {
this.feedUrl = feedUrl;
}
public String getOfferFeedUrl() {
return offerFeedUrl;
}
public void setOfferFeedUrl(String offerFeedUrl) {
this.offerFeedUrl = offerFeedUrl;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getLastFullImportAt() {
return lastFullImportAt;
}
public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
this.lastFullImportAt = lastFullImportAt;
}
public OffsetDateTime getLastOfferSyncAt() {
return lastOfferSyncAt;
}
public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
this.lastOfferSyncAt = lastOfferSyncAt;
}
}