wired services

This commit is contained in:
2026-06-12 17:21:00 +03:00
parent 282331d11f
commit 00e42adcc9
5 changed files with 402 additions and 0 deletions
@@ -0,0 +1,8 @@
package com.andwati.orders.dto.response;
import java.util.List;
public record InventoryResponse(
List<InventoryItemResponse> items
) {
}
@@ -0,0 +1,54 @@
package com.andwati.orders.services;
import com.andwati.orders.config.FareProperties;
import com.andwati.orders.dto.response.FareCalculationResponse;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
public class FareCalculationService {
private static final String FORMULA =
"max(minimumFare, (baseFare + distanceKm * perKmRate) * surgeMultiplier)";
private final FareProperties fareProperties;
public FareCalculationService(FareProperties fareProperties) {
this.fareProperties = fareProperties;
}
public FareCalculationResponse calculateFare(BigDecimal distanceKm, BigDecimal surgeMultiplier) {
if (distanceKm == null || distanceKm.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("distanceKm must be greater than 0");
}
BigDecimal effectiveSurgeMultiplier = surgeMultiplier == null
? fareProperties.defaultSurgeMultiplier()
: surgeMultiplier;
if (effectiveSurgeMultiplier.compareTo(BigDecimal.ONE) < 0) {
throw new IllegalArgumentException("surgeMultiplier must be greater than or equal to 1.0");
}
BigDecimal rawFare = fareProperties.baseFare()
.add(distanceKm.multiply(fareProperties.perKmRate()));
BigDecimal surgedFare = rawFare.multiply(effectiveSurgeMultiplier);
BigDecimal finalFare = surgedFare.max(fareProperties.minimumFare())
.setScale(2, RoundingMode.HALF_UP);
return new FareCalculationResponse(
distanceKm,
fareProperties.baseFare(),
fareProperties.perKmRate(),
effectiveSurgeMultiplier,
fareProperties.minimumFare(),
finalFare,
fareProperties.currency(),
FORMULA
);
}
}
@@ -0,0 +1,56 @@
package com.andwati.orders.services;
import com.andwati.orders.config.InventoryProperties;
import com.andwati.orders.dto.response.InventoryResponse;
import com.andwati.orders.dto.response.LowStockInventoryResponse;
import com.andwati.orders.mappers.InventoryMapper;
import com.andwati.orders.repository.InventoryItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InventoryService {
private final InventoryItemRepository inventoryItemRepository;
private final InventoryMapper inventoryMapper;
private final InventoryProperties inventoryProperties;
public InventoryService(
InventoryItemRepository inventoryItemRepository,
InventoryMapper inventoryMapper,
InventoryProperties inventoryProperties
) {
this.inventoryItemRepository = inventoryItemRepository;
this.inventoryMapper = inventoryMapper;
this.inventoryProperties = inventoryProperties;
}
@Transactional(readOnly = true)
public InventoryResponse getInventory() {
var items = inventoryItemRepository.findAllWithProducts()
.stream()
.map(inventoryMapper::toResponse)
.toList();
return new InventoryResponse(items);
}
@Transactional(readOnly = true)
public LowStockInventoryResponse getLowStockItems(Integer threshold) {
int effectiveThreshold = threshold == null
? inventoryProperties.lowStockThreshold()
: threshold;
if (effectiveThreshold < 0) {
throw new IllegalArgumentException("threshold must be greater than or equal to 0");
}
var items = inventoryItemRepository.findLowStockItems(effectiveThreshold)
.stream()
.map(inventoryMapper::toResponse)
.toList();
return new LowStockInventoryResponse(effectiveThreshold, items);
}
}
@@ -0,0 +1,261 @@
package com.andwati.orders.services;
import com.andwati.orders.dto.request.CreateOrderRequest;
import com.andwati.orders.dto.response.CancelOrderResponse;
import com.andwati.orders.dto.response.OrderResponse;
import com.andwati.orders.dto.response.OrderSummaryResponse;
import com.andwati.orders.dto.response.PageResponse;
import com.andwati.orders.exception.*;
import com.andwati.orders.mappers.OrderMapper;
import com.andwati.orders.model.*;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.OrderRepository;
import com.andwati.orders.repository.ProductRepository;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final InventoryItemRepository inventoryItemRepository;
private final OrderMapper orderMapper;
public OrderService(
OrderRepository orderRepository,
ProductRepository productRepository,
InventoryItemRepository inventoryItemRepository,
OrderMapper orderMapper
) {
this.orderRepository = orderRepository;
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
this.orderMapper = orderMapper;
}
@Transactional
public OrderResponse placeOrder(CreateOrderRequest request) {
Map<UUID, Integer> requestedQuantities = normalizeItems(request);
Map<UUID, Product> productsById = productRepository.findByIdIn(requestedQuantities.keySet())
.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
validateProductsExistAndActive(requestedQuantities, productsById);
Map<UUID, InventoryItem> inventoryByProductId = inventoryItemRepository
.findByProductIdsForUpdate(requestedQuantities.keySet())
.stream()
.collect(Collectors.toMap(
inventory -> inventory.getProduct().getId(),
Function.identity()
));
validateInventoryExists(requestedQuantities, inventoryByProductId);
validateStockAvailability(requestedQuantities, inventoryByProductId);
Order order = new Order();
order.setCustomerName(request.customerName());
order.setStatus(OrderStatus.PLACED);
order.setCurrency("KES");
order.setCreatedAt(Instant.now());
for (Map.Entry<UUID, Integer> entry : requestedQuantities.entrySet()) {
UUID productId = entry.getKey();
int quantity = entry.getValue();
Product product = productsById.get(productId);
InventoryItem inventoryItem = inventoryByProductId.get(productId);
inventoryItem.deduct(quantity);
OrderItem orderItem = OrderItem.from(product, quantity);
order.addItem(orderItem);
}
order.recalculateTotal();
Order savedOrder = orderRepository.save(order);
return orderMapper.toResponse(savedOrder);
}
@Transactional
public CancelOrderResponse cancelOrder(UUID orderId) {
Order order = orderRepository.findWithItemsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (!order.isCancellable()) {
throw new OrderNotCancellableException(orderId);
}
var productIds = order.getItems()
.stream()
.map(item -> item.getProduct().getId())
.toList();
Map<UUID, InventoryItem> inventoryByProductId = inventoryItemRepository
.findByProductIdsForUpdate(productIds)
.stream()
.collect(Collectors.toMap(
inventory -> inventory.getProduct().getId(),
Function.identity()
));
for (OrderItem item : order.getItems()) {
UUID productId = item.getProduct().getId();
InventoryItem inventoryItem = inventoryByProductId.get(productId);
if (inventoryItem == null) {
throw new InventoryNotFoundException(productId);
}
inventoryItem.restore(item.getQuantity());
}
order.cancel();
return orderMapper.toCancelResponse(order);
}
@Transactional(readOnly = true)
public OrderResponse getOrder(UUID orderId) {
Order order = orderRepository.findWithItemsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
return orderMapper.toResponse(order);
}
@Transactional(readOnly = true)
public PageResponse<OrderSummaryResponse> listOrders(
OrderStatus status,
LocalDate from,
LocalDate to,
int page,
int size
) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
var pageable = PageRequest.of(
safePage,
safeSize,
Sort.by(Sort.Direction.DESC, "createdAt")
);
var result = orderRepository.findAll(buildOrderSpecification(status, from, to), pageable);
var items = result.getContent()
.stream()
.map(orderMapper::toSummaryResponse)
.toList();
return new PageResponse<>(
items,
result.getNumber(),
result.getSize(),
result.getTotalElements(),
result.getTotalPages()
);
}
private Map<UUID, Integer> normalizeItems(CreateOrderRequest request) {
Map<UUID, Integer> quantities = new HashMap<>();
request.items().forEach(item -> quantities.merge(
item.productId(),
item.quantity(),
Integer::sum
));
return quantities;
}
private void validateProductsExistAndActive(
Map<UUID, Integer> requestedQuantities,
Map<UUID, Product> productsById
) {
for (UUID productId : requestedQuantities.keySet()) {
Product product = productsById.get(productId);
if (product == null) {
throw new ProductNotFoundException(productId);
}
if (!product.isActive()) {
throw new IllegalArgumentException("Product " + product.getName() + " is not active");
}
}
}
private void validateInventoryExists(
Map<UUID, Integer> requestedQuantities,
Map<UUID, InventoryItem> inventoryByProductId
) {
for (UUID productId : requestedQuantities.keySet()) {
if (!inventoryByProductId.containsKey(productId)) {
throw new InventoryNotFoundException(productId);
}
}
}
private void validateStockAvailability(
Map<UUID, Integer> requestedQuantities,
Map<UUID, InventoryItem> inventoryByProductId
) {
for (Map.Entry<UUID, Integer> entry : requestedQuantities.entrySet()) {
UUID productId = entry.getKey();
int requestedQuantity = entry.getValue();
InventoryItem inventoryItem = inventoryByProductId.get(productId);
if (!inventoryItem.hasEnoughStock(requestedQuantity)) {
throw new InsufficientStockException(
productId,
inventoryItem.getProduct().getName(),
requestedQuantity,
inventoryItem.getAvailableQuantity()
);
}
}
}
private Specification<Order> buildOrderSpecification(
OrderStatus status,
LocalDate from,
LocalDate to
) {
return (root, query, criteriaBuilder) -> {
var predicates = new java.util.ArrayList<Predicate>();
if (status != null) {
predicates.add(criteriaBuilder.equal(root.get("status"), status));
}
if (from != null) {
Instant fromInstant = from.atStartOfDay().toInstant(ZoneOffset.UTC);
predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get("createdAt"), fromInstant));
}
if (to != null) {
Instant toExclusive = to.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC);
predicates.add(criteriaBuilder.lessThan(root.get("createdAt"), toExclusive));
}
return criteriaBuilder.and(predicates.toArray(Predicate[]::new));
};
}
}
@@ -0,0 +1,23 @@
package com.andwati.orders.services;
import com.andwati.orders.exception.ProductNotFoundException;
import com.andwati.orders.model.Product;
import com.andwati.orders.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProductOrThrow(UUID id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}