feat(back-end) shop logic
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.ShopCartAddItemRequest;
|
||||
import com.printcalculator.dto.ShopCartUpdateItemRequest;
|
||||
import com.printcalculator.service.shop.ShopCartCookieService;
|
||||
import com.printcalculator.service.shop.ShopCartService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/shop/cart")
|
||||
public class ShopCartController {
|
||||
private final ShopCartService shopCartService;
|
||||
private final ShopCartCookieService shopCartCookieService;
|
||||
|
||||
public ShopCartController(ShopCartService shopCartService, ShopCartCookieService shopCartCookieService) {
|
||||
this.shopCartService = shopCartService;
|
||||
this.shopCartCookieService = shopCartCookieService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getCart(HttpServletRequest request, HttpServletResponse response) {
|
||||
ShopCartService.CartResult result = shopCartService.loadCart(request);
|
||||
applyCookie(response, result);
|
||||
return ResponseEntity.ok(result.response());
|
||||
}
|
||||
|
||||
@PostMapping("/items")
|
||||
public ResponseEntity<?> addItem(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
@Valid @RequestBody ShopCartAddItemRequest payload) {
|
||||
ShopCartService.CartResult result = shopCartService.addItem(request, payload);
|
||||
applyCookie(response, result);
|
||||
return ResponseEntity.ok(result.response());
|
||||
}
|
||||
|
||||
@PatchMapping("/items/{lineItemId}")
|
||||
public ResponseEntity<?> updateItem(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
@PathVariable UUID lineItemId,
|
||||
@Valid @RequestBody ShopCartUpdateItemRequest payload) {
|
||||
ShopCartService.CartResult result = shopCartService.updateItem(request, lineItemId, payload);
|
||||
applyCookie(response, result);
|
||||
return ResponseEntity.ok(result.response());
|
||||
}
|
||||
|
||||
@DeleteMapping("/items/{lineItemId}")
|
||||
public ResponseEntity<?> removeItem(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
@PathVariable UUID lineItemId) {
|
||||
ShopCartService.CartResult result = shopCartService.removeItem(request, lineItemId);
|
||||
applyCookie(response, result);
|
||||
return ResponseEntity.ok(result.response());
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ResponseEntity<?> clearCart(HttpServletRequest request, HttpServletResponse response) {
|
||||
ShopCartService.CartResult result = shopCartService.clearCart(request);
|
||||
applyCookie(response, result);
|
||||
return ResponseEntity.ok(result.response());
|
||||
}
|
||||
|
||||
private void applyCookie(HttpServletResponse response, ShopCartService.CartResult result) {
|
||||
if (result.clearCookie()) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildClearCookie().toString());
|
||||
return;
|
||||
}
|
||||
if (result.sessionId() != null) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildSessionCookie(result.sessionId()).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class ShopCartAddItemRequest {
|
||||
@NotNull
|
||||
private UUID shopProductVariantId;
|
||||
|
||||
@Min(1)
|
||||
private Integer quantity = 1;
|
||||
|
||||
public UUID getShopProductVariantId() {
|
||||
return shopProductVariantId;
|
||||
}
|
||||
|
||||
public void setShopProductVariantId(UUID shopProductVariantId) {
|
||||
this.shopProductVariantId = shopProductVariantId;
|
||||
}
|
||||
|
||||
public Integer getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(Integer quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.printcalculator.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class ShopCartUpdateItemRequest {
|
||||
@NotNull
|
||||
@Min(1)
|
||||
private Integer quantity;
|
||||
|
||||
public Integer getQuantity() {
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public void setQuantity(Integer quantity) {
|
||||
this.quantity = quantity;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,17 @@ import com.printcalculator.entity.QuoteLineItem;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
||||
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
||||
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
|
||||
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
|
||||
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
||||
UUID quoteSessionId,
|
||||
String lineItemType,
|
||||
UUID shopProductVariantId
|
||||
);
|
||||
boolean existsByFilamentVariant_Id(Long filamentVariantId);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import com.printcalculator.entity.QuoteSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
|
||||
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
|
||||
|
||||
List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses);
|
||||
|
||||
Optional<QuoteSession> findByIdAndSessionType(UUID id, String sessionType);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ public class QuoteSessionResponseAssembler {
|
||||
return response;
|
||||
}
|
||||
|
||||
public Map<String, Object> emptyCart() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("session", null);
|
||||
response.put("items", List.of());
|
||||
response.put("printItemsTotalChf", BigDecimal.ZERO);
|
||||
response.put("cadTotalChf", BigDecimal.ZERO);
|
||||
response.put("itemsTotalChf", BigDecimal.ZERO);
|
||||
response.put("baseSetupCostChf", BigDecimal.ZERO);
|
||||
response.put("nozzleChangeCostChf", BigDecimal.ZERO);
|
||||
response.put("setupCostChf", BigDecimal.ZERO);
|
||||
response.put("shippingCostChf", BigDecimal.ZERO);
|
||||
response.put("globalMachineCostChf", BigDecimal.ZERO);
|
||||
response.put("grandTotalChf", BigDecimal.ZERO);
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", item.getId());
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.printcalculator.service.shop;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ShopCartCookieService {
|
||||
public static final String COOKIE_NAME = "shop_cart_session";
|
||||
private static final String COOKIE_PATH = "/api/shop";
|
||||
|
||||
private final long cookieTtlDays;
|
||||
private final boolean secureCookie;
|
||||
private final String sameSite;
|
||||
|
||||
public ShopCartCookieService(
|
||||
@Value("${shop.cart.cookie.ttl-days:30}") long cookieTtlDays,
|
||||
@Value("${shop.cart.cookie.secure:false}") boolean secureCookie,
|
||||
@Value("${shop.cart.cookie.same-site:Lax}") String sameSite
|
||||
) {
|
||||
this.cookieTtlDays = cookieTtlDays;
|
||||
this.secureCookie = secureCookie;
|
||||
this.sameSite = sameSite;
|
||||
}
|
||||
|
||||
public Optional<UUID> extractSessionId(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null || cookies.length == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
for (Cookie cookie : cookies) {
|
||||
if (!COOKIE_NAME.equals(cookie.getName())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
String value = cookie.getValue();
|
||||
if (value == null || value.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(UUID.fromString(value.trim()));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public boolean hasCartCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null || cookies.length == 0) {
|
||||
return false;
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (COOKIE_NAME.equals(cookie.getName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ResponseCookie buildSessionCookie(UUID sessionId) {
|
||||
return ResponseCookie.from(COOKIE_NAME, sessionId.toString())
|
||||
.path(COOKIE_PATH)
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite(sameSite)
|
||||
.maxAge(Duration.ofDays(Math.max(cookieTtlDays, 1)))
|
||||
.build();
|
||||
}
|
||||
|
||||
public ResponseCookie buildClearCookie() {
|
||||
return ResponseCookie.from(COOKIE_NAME, "")
|
||||
.path(COOKIE_PATH)
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite(sameSite)
|
||||
.maxAge(Duration.ZERO)
|
||||
.build();
|
||||
}
|
||||
|
||||
public long getCookieTtlDays() {
|
||||
return cookieTtlDays;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.printcalculator.service.shop;
|
||||
|
||||
import com.printcalculator.dto.ShopCartAddItemRequest;
|
||||
import com.printcalculator.dto.ShopCartUpdateItemRequest;
|
||||
import com.printcalculator.entity.QuoteLineItem;
|
||||
import com.printcalculator.entity.QuoteSession;
|
||||
import com.printcalculator.entity.ShopCategory;
|
||||
import com.printcalculator.entity.ShopProduct;
|
||||
import com.printcalculator.entity.ShopProductModelAsset;
|
||||
import com.printcalculator.entity.ShopProductVariant;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.repository.ShopProductModelAssetRepository;
|
||||
import com.printcalculator.repository.ShopProductVariantRepository;
|
||||
import com.printcalculator.service.QuoteSessionTotalsService;
|
||||
import com.printcalculator.service.quote.QuoteSessionResponseAssembler;
|
||||
import com.printcalculator.service.quote.QuoteStorageService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class ShopCartService {
|
||||
private static final String SHOP_CART_SESSION_TYPE = "SHOP_CART";
|
||||
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
|
||||
private static final String ACTIVE_STATUS = "ACTIVE";
|
||||
private static final String EXPIRED_STATUS = "EXPIRED";
|
||||
private static final String CONVERTED_STATUS = "CONVERTED";
|
||||
|
||||
private final QuoteSessionRepository quoteSessionRepository;
|
||||
private final QuoteLineItemRepository quoteLineItemRepository;
|
||||
private final ShopProductVariantRepository shopProductVariantRepository;
|
||||
private final ShopProductModelAssetRepository shopProductModelAssetRepository;
|
||||
private final QuoteSessionTotalsService quoteSessionTotalsService;
|
||||
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
|
||||
private final QuoteStorageService quoteStorageService;
|
||||
private final ShopStorageService shopStorageService;
|
||||
private final ShopCartCookieService shopCartCookieService;
|
||||
|
||||
public ShopCartService(
|
||||
QuoteSessionRepository quoteSessionRepository,
|
||||
QuoteLineItemRepository quoteLineItemRepository,
|
||||
ShopProductVariantRepository shopProductVariantRepository,
|
||||
ShopProductModelAssetRepository shopProductModelAssetRepository,
|
||||
QuoteSessionTotalsService quoteSessionTotalsService,
|
||||
QuoteSessionResponseAssembler quoteSessionResponseAssembler,
|
||||
QuoteStorageService quoteStorageService,
|
||||
ShopStorageService shopStorageService,
|
||||
ShopCartCookieService shopCartCookieService
|
||||
) {
|
||||
this.quoteSessionRepository = quoteSessionRepository;
|
||||
this.quoteLineItemRepository = quoteLineItemRepository;
|
||||
this.shopProductVariantRepository = shopProductVariantRepository;
|
||||
this.shopProductModelAssetRepository = shopProductModelAssetRepository;
|
||||
this.quoteSessionTotalsService = quoteSessionTotalsService;
|
||||
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
|
||||
this.quoteStorageService = quoteStorageService;
|
||||
this.shopStorageService = shopStorageService;
|
||||
this.shopCartCookieService = shopCartCookieService;
|
||||
}
|
||||
|
||||
public CartResult loadCart(HttpServletRequest request) {
|
||||
boolean hadCookie = shopCartCookieService.hasCartCookie(request);
|
||||
Optional<QuoteSession> session = resolveValidCartSession(request);
|
||||
if (session.isEmpty()) {
|
||||
return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie);
|
||||
}
|
||||
|
||||
QuoteSession validSession = session.get();
|
||||
touchSession(validSession);
|
||||
return CartResult.withSession(buildCartResponse(validSession), validSession.getId(), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartResult addItem(HttpServletRequest request, ShopCartAddItemRequest payload) {
|
||||
int quantityToAdd = normalizeQuantity(payload != null ? payload.getQuantity() : null);
|
||||
ShopProductVariant variant = getPurchasableVariant(payload != null ? payload.getShopProductVariantId() : null);
|
||||
QuoteSession session = resolveValidCartSession(request).orElseGet(this::createCartSession);
|
||||
touchSession(session);
|
||||
|
||||
QuoteLineItem lineItem = quoteLineItemRepository
|
||||
.findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
||||
session.getId(),
|
||||
SHOP_LINE_ITEM_TYPE,
|
||||
variant.getId()
|
||||
)
|
||||
.orElseGet(() -> buildShopLineItem(session, variant));
|
||||
|
||||
int existingQuantity = lineItem.getQuantity() != null && lineItem.getQuantity() > 0
|
||||
? lineItem.getQuantity()
|
||||
: 0;
|
||||
int newQuantity = existingQuantity + quantityToAdd;
|
||||
lineItem.setQuantity(newQuantity);
|
||||
refreshLineItemSnapshot(lineItem, variant);
|
||||
lineItem.setUpdatedAt(OffsetDateTime.now());
|
||||
quoteLineItemRepository.save(lineItem);
|
||||
|
||||
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartResult updateItem(HttpServletRequest request, UUID lineItemId, ShopCartUpdateItemRequest payload) {
|
||||
QuoteSession session = resolveValidCartSession(request)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found"));
|
||||
|
||||
QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found"));
|
||||
|
||||
if (!SHOP_LINE_ITEM_TYPE.equals(item.getLineItemType())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid cart item type");
|
||||
}
|
||||
|
||||
item.setQuantity(normalizeQuantity(payload != null ? payload.getQuantity() : null));
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
|
||||
if (item.getShopProductVariant() != null) {
|
||||
refreshLineItemSnapshot(item, item.getShopProductVariant());
|
||||
}
|
||||
|
||||
quoteLineItemRepository.save(item);
|
||||
touchSession(session);
|
||||
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartResult removeItem(HttpServletRequest request, UUID lineItemId) {
|
||||
QuoteSession session = resolveValidCartSession(request)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found"));
|
||||
|
||||
QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found"));
|
||||
|
||||
quoteLineItemRepository.delete(item);
|
||||
touchSession(session);
|
||||
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartResult clearCart(HttpServletRequest request) {
|
||||
boolean hadCookie = shopCartCookieService.hasCartCookie(request);
|
||||
Optional<QuoteSession> session = resolveValidCartSession(request);
|
||||
if (session.isPresent()) {
|
||||
QuoteSession current = session.get();
|
||||
quoteSessionRepository.delete(current);
|
||||
}
|
||||
return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie);
|
||||
}
|
||||
|
||||
private Optional<QuoteSession> resolveValidCartSession(HttpServletRequest request) {
|
||||
Optional<UUID> sessionId = shopCartCookieService.extractSessionId(request);
|
||||
if (sessionId.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<QuoteSession> session = quoteSessionRepository.findByIdAndSessionType(sessionId.get(), SHOP_CART_SESSION_TYPE);
|
||||
if (session.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QuoteSession quoteSession = session.get();
|
||||
if (isSessionUnavailable(quoteSession)) {
|
||||
if (!EXPIRED_STATUS.equals(quoteSession.getStatus()) && !CONVERTED_STATUS.equals(quoteSession.getStatus())) {
|
||||
quoteSession.setStatus(EXPIRED_STATUS);
|
||||
quoteSessionRepository.save(quoteSession);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(quoteSession);
|
||||
}
|
||||
|
||||
private QuoteSession createCartSession() {
|
||||
QuoteSession session = new QuoteSession();
|
||||
session.setStatus(ACTIVE_STATUS);
|
||||
session.setSessionType(SHOP_CART_SESSION_TYPE);
|
||||
session.setPricingVersion("v1");
|
||||
session.setMaterialCode("SHOP");
|
||||
session.setSupportsEnabled(false);
|
||||
session.setCreatedAt(OffsetDateTime.now());
|
||||
session.setExpiresAt(nowPlusCookieTtl());
|
||||
session.setSetupCostChf(BigDecimal.ZERO);
|
||||
return quoteSessionRepository.save(session);
|
||||
}
|
||||
|
||||
private Map<String, Object> buildCartResponse(QuoteSession session) {
|
||||
List<QuoteLineItem> items = quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(session.getId());
|
||||
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
|
||||
return quoteSessionResponseAssembler.assemble(session, items, totals);
|
||||
}
|
||||
|
||||
private QuoteLineItem buildShopLineItem(QuoteSession session, ShopProductVariant variant) {
|
||||
ShopProduct product = variant.getProduct();
|
||||
ShopProductModelAsset modelAsset = product != null ? shopProductModelAssetRepository.findByProduct_Id(product.getId()).orElse(null) : null;
|
||||
|
||||
QuoteLineItem item = new QuoteLineItem();
|
||||
item.setQuoteSession(session);
|
||||
item.setStatus("READY");
|
||||
item.setLineItemType(SHOP_LINE_ITEM_TYPE);
|
||||
item.setQuantity(0);
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
item.setSupportsEnabled(false);
|
||||
item.setInfillPercent(0);
|
||||
item.setPricingBreakdown(new HashMap<>());
|
||||
|
||||
refreshLineItemSnapshot(item, variant);
|
||||
applyModelAssetSnapshot(item, session, modelAsset);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void refreshLineItemSnapshot(QuoteLineItem item, ShopProductVariant variant) {
|
||||
ShopProduct product = variant.getProduct();
|
||||
ShopCategory category = product != null ? product.getCategory() : null;
|
||||
|
||||
item.setShopProduct(product);
|
||||
item.setShopProductVariant(variant);
|
||||
item.setShopProductSlug(product != null ? product.getSlug() : null);
|
||||
item.setShopProductName(product != null ? product.getName() : null);
|
||||
item.setShopVariantLabel(variant.getVariantLabel());
|
||||
item.setShopVariantColorName(variant.getColorName());
|
||||
item.setShopVariantColorHex(variant.getColorHex());
|
||||
item.setDisplayName(product != null ? product.getName() : item.getDisplayName());
|
||||
item.setColorCode(variant.getColorName());
|
||||
item.setMaterialCode(variant.getInternalMaterialCode());
|
||||
item.setQuality(null);
|
||||
item.setUnitPriceChf(variant.getPriceChf() != null ? variant.getPriceChf() : BigDecimal.ZERO);
|
||||
|
||||
Map<String, Object> breakdown = item.getPricingBreakdown() != null
|
||||
? new HashMap<>(item.getPricingBreakdown())
|
||||
: new HashMap<>();
|
||||
breakdown.put("type", SHOP_LINE_ITEM_TYPE);
|
||||
breakdown.put("unitPriceChf", item.getUnitPriceChf());
|
||||
item.setPricingBreakdown(breakdown);
|
||||
}
|
||||
|
||||
private void applyModelAssetSnapshot(QuoteLineItem item, QuoteSession session, ShopProductModelAsset modelAsset) {
|
||||
if (modelAsset == null) {
|
||||
if (item.getOriginalFilename() == null || item.getOriginalFilename().isBlank()) {
|
||||
item.setOriginalFilename(item.getShopProductSlug() != null ? item.getShopProductSlug() : "shop-product");
|
||||
}
|
||||
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
||||
item.setStoredPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
item.setOriginalFilename(modelAsset.getOriginalFilename());
|
||||
item.setBoundingBoxXMm(modelAsset.getBoundingBoxXMm() != null ? modelAsset.getBoundingBoxXMm() : BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(modelAsset.getBoundingBoxYMm() != null ? modelAsset.getBoundingBoxYMm() : BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(modelAsset.getBoundingBoxZMm() != null ? modelAsset.getBoundingBoxZMm() : BigDecimal.ZERO);
|
||||
|
||||
String copiedStoredPath = copyModelAssetIntoSession(session, modelAsset);
|
||||
item.setStoredPath(copiedStoredPath);
|
||||
}
|
||||
|
||||
private String copyModelAssetIntoSession(QuoteSession session, ShopProductModelAsset modelAsset) {
|
||||
if (session == null || modelAsset == null || modelAsset.getProduct() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path source = shopStorageService.resolveStoredProductPath(
|
||||
modelAsset.getStoredRelativePath(),
|
||||
modelAsset.getProduct().getId()
|
||||
);
|
||||
if (source == null || !Files.exists(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Path sessionDir = quoteStorageService.sessionStorageDir(session.getId());
|
||||
String extension = quoteStorageService.getSafeExtension(modelAsset.getOriginalFilename(), "stl");
|
||||
Path destination = quoteStorageService.resolveSessionPath(
|
||||
sessionDir,
|
||||
UUID.randomUUID() + "." + extension
|
||||
);
|
||||
Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING);
|
||||
return quoteStorageService.toStoredPath(destination);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ShopProductVariant getPurchasableVariant(UUID variantId) {
|
||||
if (variantId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "shopProductVariantId is required");
|
||||
}
|
||||
|
||||
ShopProductVariant variant = shopProductVariantRepository.findById(variantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not found"));
|
||||
|
||||
ShopProduct product = variant.getProduct();
|
||||
ShopCategory category = product != null ? product.getCategory() : null;
|
||||
if (product == null
|
||||
|| category == null
|
||||
|| !Boolean.TRUE.equals(variant.getIsActive())
|
||||
|| !Boolean.TRUE.equals(product.getIsActive())
|
||||
|| !Boolean.TRUE.equals(category.getIsActive())) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not available");
|
||||
}
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
private void touchSession(QuoteSession session) {
|
||||
session.setStatus(ACTIVE_STATUS);
|
||||
session.setExpiresAt(nowPlusCookieTtl());
|
||||
quoteSessionRepository.save(session);
|
||||
}
|
||||
|
||||
private OffsetDateTime nowPlusCookieTtl() {
|
||||
return OffsetDateTime.now().plusDays(Math.max(shopCartCookieService.getCookieTtlDays(), 1));
|
||||
}
|
||||
|
||||
private boolean isSessionUnavailable(QuoteSession session) {
|
||||
if (session == null) {
|
||||
return true;
|
||||
}
|
||||
if (!SHOP_CART_SESSION_TYPE.equalsIgnoreCase(session.getSessionType())) {
|
||||
return true;
|
||||
}
|
||||
if (!ACTIVE_STATUS.equalsIgnoreCase(session.getStatus())) {
|
||||
return true;
|
||||
}
|
||||
if (CONVERTED_STATUS.equalsIgnoreCase(session.getStatus())) {
|
||||
return true;
|
||||
}
|
||||
OffsetDateTime expiresAt = session.getExpiresAt();
|
||||
return expiresAt != null && expiresAt.isBefore(OffsetDateTime.now());
|
||||
}
|
||||
|
||||
private int normalizeQuantity(Integer quantity) {
|
||||
if (quantity == null || quantity < 1) {
|
||||
return 1;
|
||||
}
|
||||
return quantity;
|
||||
}
|
||||
|
||||
public record CartResult(Map<String, Object> response, UUID sessionId, boolean clearCookie) {
|
||||
public static CartResult withSession(Map<String, Object> response, UUID sessionId, boolean clearCookie) {
|
||||
return new CartResult(response, sessionId, clearCookie);
|
||||
}
|
||||
|
||||
public static CartResult empty(Map<String, Object> response, boolean clearCookie) {
|
||||
return new CartResult(response, null, clearCookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.printcalculator.service.shop;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ShopStorageService {
|
||||
private final Path storageRoot;
|
||||
|
||||
public ShopStorageService(@Value("${shop.storage.root:storage_shop}") String storageRoot) {
|
||||
this.storageRoot = Paths.get(storageRoot).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public Path productModelStorageDir(UUID productId) throws IOException {
|
||||
Path dir = storageRoot.resolve(Path.of("products", productId.toString(), "3d-models")).normalize();
|
||||
if (!dir.startsWith(storageRoot)) {
|
||||
throw new IOException("Invalid shop product storage path");
|
||||
}
|
||||
Files.createDirectories(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
public Path resolveStoredProductPath(String storedRelativePath, UUID expectedProductId) {
|
||||
if (storedRelativePath == null || storedRelativePath.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Path raw = Path.of(storedRelativePath).normalize();
|
||||
Path resolved = raw.isAbsolute() ? raw : storageRoot.resolve(raw).normalize();
|
||||
Path expectedPrefix = storageRoot.resolve(Path.of("products", expectedProductId.toString())).normalize();
|
||||
if (!resolved.startsWith(expectedPrefix)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch (InvalidPathException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String toStoredPath(Path absolutePath) {
|
||||
return storageRoot.relativize(absolutePath.toAbsolutePath().normalize()).toString();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ media.storage.root=${MEDIA_STORAGE_ROOT:storage_media}
|
||||
media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media}
|
||||
media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg}
|
||||
media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400}
|
||||
shop.storage.root=${SHOP_STORAGE_ROOT:storage_shop}
|
||||
shop.cart.cookie.ttl-days=${SHOP_CART_COOKIE_TTL_DAYS:30}
|
||||
shop.cart.cookie.secure=${SHOP_CART_COOKIE_SECURE:false}
|
||||
shop.cart.cookie.same-site=${SHOP_CART_COOKIE_SAME_SITE:Lax}
|
||||
|
||||
# TWINT Configuration
|
||||
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
|
||||
|
||||
Reference in New Issue
Block a user