mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
added server side price sorting endpoints
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
package group.goforward.battlbuilder.catalog.query;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface ProductWithBestPrice {
|
||||||
|
Long getId();
|
||||||
|
UUID getUuid();
|
||||||
|
String getName();
|
||||||
|
String getSlug();
|
||||||
|
String getPlatform();
|
||||||
|
String getPartRole();
|
||||||
|
|
||||||
|
BigDecimal getBestPrice(); // derived
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ package group.goforward.battlbuilder.controllers;
|
|||||||
import group.goforward.battlbuilder.services.ProductQueryService;
|
import group.goforward.battlbuilder.services.ProductQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -23,17 +24,26 @@ public class ProductV1Controller {
|
|||||||
this.productQueryService = productQueryService;
|
this.productQueryService = productQueryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product list endpoint
|
||||||
|
* Example:
|
||||||
|
* /api/v1/products?platform=AR-15&partRoles=upper-receiver&priceSort=price_asc&page=0&size=50
|
||||||
|
*
|
||||||
|
* NOTE: do NOT use `sort=` here — Spring reserves it for Pageable sorting.
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
value = "gunbuilderProductsV1",
|
value = "gunbuilderProductsV1",
|
||||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles) + '::' + #priceSort + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
|
||||||
)
|
)
|
||||||
public Page<ProductSummaryDto> getProducts(
|
public Page<ProductSummaryDto> getProducts(
|
||||||
@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,
|
||||||
|
@RequestParam(name = "priceSort", defaultValue = "price_asc") String priceSort,
|
||||||
@PageableDefault(size = 50) Pageable pageable
|
@PageableDefault(size = 50) Pageable pageable
|
||||||
) {
|
) {
|
||||||
return productQueryService.getProductsPage(platform, partRoles, pageable);
|
ProductSort sortEnum = ProductSort.from(priceSort);
|
||||||
|
return productQueryService.getProductsPage(platform, partRoles, pageable, sortEnum);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/offers")
|
@GetMapping("/{id}/offers")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package group.goforward.battlbuilder.repos;
|
|||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Brand;
|
import group.goforward.battlbuilder.model.Brand;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
|
import group.goforward.battlbuilder.catalog.query.ProductWithBestPrice;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repos.projections.CatalogRow;
|
import group.goforward.battlbuilder.repos.projections.CatalogRow;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow;
|
||||||
@@ -625,6 +626,28 @@ ORDER BY productCount DESC
|
|||||||
Pageable pageable
|
Pageable pageable
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------
|
||||||
|
// Best Price
|
||||||
|
// -------------------------------------------------
|
||||||
|
@Query("""
|
||||||
|
select
|
||||||
|
p.id as id,
|
||||||
|
p.uuid as uuid,
|
||||||
|
p.name as name,
|
||||||
|
p.slug as slug,
|
||||||
|
p.platform as platform,
|
||||||
|
p.partRole as partRole,
|
||||||
|
min(o.price) as bestPrice
|
||||||
|
from Product p
|
||||||
|
join ProductOffer o on o.product.id = p.id
|
||||||
|
where (:platform is null or p.platform = :platform)
|
||||||
|
and o.inStock = true
|
||||||
|
group by p.id, p.uuid, p.name, p.slug, p.platform, p.partRole
|
||||||
|
""")
|
||||||
|
Page<ProductWithBestPrice> findProductsWithBestPriceInStock(
|
||||||
|
@Param("platform") String platform,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package group.goforward.battlbuilder.services;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ProductQueryService {
|
public interface ProductQueryService {
|
||||||
|
|
||||||
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||||
@@ -16,5 +16,10 @@ public interface ProductQueryService {
|
|||||||
|
|
||||||
ProductSummaryDto getProductById(Integer productId);
|
ProductSummaryDto getProductById(Integer productId);
|
||||||
|
|
||||||
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
|
Page<ProductSummaryDto> getProductsPage(
|
||||||
|
String platform,
|
||||||
|
List<String> partRoles,
|
||||||
|
Pageable pageable,
|
||||||
|
ProductSort sort
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import group.goforward.battlbuilder.services.ProductQueryService;
|
|||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -16,6 +16,8 @@ import java.util.stream.Collectors;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@@ -79,24 +81,37 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
|
public Page<ProductSummaryDto> getProductsPage(
|
||||||
|
String platform,
|
||||||
|
List<String> partRoles,
|
||||||
|
Pageable pageable,
|
||||||
|
ProductSort sort
|
||||||
|
) {
|
||||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||||
|
|
||||||
|
// IMPORTANT: ignore Pageable sorting (because we are doing our own "best price" logic)
|
||||||
|
// If the client accidentally passes ?sort=..., Spring Data will try ordering by a Product field.
|
||||||
|
// We'll strip it to be safe.
|
||||||
|
Pageable safePageable = pageable;
|
||||||
|
if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) {
|
||||||
|
safePageable = Pageable.ofSize(pageable.getPageSize()).withPage(pageable.getPageNumber());
|
||||||
|
}
|
||||||
|
|
||||||
Page<Product> productPage;
|
Page<Product> productPage;
|
||||||
|
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findAllWithBrand(pageable)
|
? productRepository.findAllWithBrand(safePageable)
|
||||||
: productRepository.findByPlatformWithBrand(platform, pageable);
|
: productRepository.findByPlatformWithBrand(platform, safePageable);
|
||||||
} else {
|
} else {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
? productRepository.findByPartRoleInWithBrand(partRoles, safePageable)
|
||||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, safePageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Product> products = productPage.getContent();
|
List<Product> products = productPage.getContent();
|
||||||
if (products.isEmpty()) {
|
if (products.isEmpty()) {
|
||||||
return Page.empty(pageable);
|
return Page.empty(safePageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||||
@@ -108,6 +123,7 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
|
// Build DTOs (same as before)
|
||||||
List<ProductSummaryDto> dtos = products.stream()
|
List<ProductSummaryDto> dtos = products.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
List<ProductOffer> offersForProduct =
|
List<ProductOffer> offersForProduct =
|
||||||
@@ -122,7 +138,17 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
|
// Phase 3 "server-side sort by price" (within the page for now)
|
||||||
|
Comparator<ProductSummaryDto> byPriceNullLast =
|
||||||
|
Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||||
|
|
||||||
|
if (sort == ProductSort.PRICE_DESC) {
|
||||||
|
dtos = dtos.stream().sorted(byPriceNullLast.reversed()).toList();
|
||||||
|
} else {
|
||||||
|
dtos = dtos.stream().sorted(byPriceNullLast).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PageImpl<>(dtos, safePageable, productPage.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto.catalog;
|
||||||
|
|
||||||
|
public enum ProductSort {
|
||||||
|
PRICE_ASC,
|
||||||
|
PRICE_DESC;
|
||||||
|
|
||||||
|
public static ProductSort from(String raw) {
|
||||||
|
if (raw == null) return PRICE_ASC;
|
||||||
|
String s = raw.trim().toLowerCase();
|
||||||
|
return switch (s) {
|
||||||
|
case "price_desc", "price-desc", "desc" -> PRICE_DESC;
|
||||||
|
default -> PRICE_ASC;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user