diff --git a/orders-backend/src/main/java/com/andwati/orders/dto/response/InventoryResponse.java b/orders-backend/src/main/java/com/andwati/orders/dto/response/InventoryResponse.java new file mode 100644 index 0000000..277d170 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/dto/response/InventoryResponse.java @@ -0,0 +1,8 @@ +package com.andwati.orders.dto.response; + +import java.util.List; + +public record InventoryResponse( + List items +) { +} \ No newline at end of file diff --git a/orders-backend/src/main/java/com/andwati/orders/services/FareCalculationService.java b/orders-backend/src/main/java/com/andwati/orders/services/FareCalculationService.java new file mode 100644 index 0000000..92b2cc7 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/FareCalculationService.java @@ -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 + ); + } +} \ No newline at end of file diff --git a/orders-backend/src/main/java/com/andwati/orders/services/InventoryService.java b/orders-backend/src/main/java/com/andwati/orders/services/InventoryService.java new file mode 100644 index 0000000..4cd307c --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/InventoryService.java @@ -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); + } +} \ No newline at end of file diff --git a/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java b/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java new file mode 100644 index 0000000..574c515 --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/OrderService.java @@ -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 requestedQuantities = normalizeItems(request); + + Map productsById = productRepository.findByIdIn(requestedQuantities.keySet()) + .stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + validateProductsExistAndActive(requestedQuantities, productsById); + + Map 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 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 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 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 normalizeItems(CreateOrderRequest request) { + Map quantities = new HashMap<>(); + + request.items().forEach(item -> quantities.merge( + item.productId(), + item.quantity(), + Integer::sum + )); + + return quantities; + } + + private void validateProductsExistAndActive( + Map requestedQuantities, + Map 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 requestedQuantities, + Map inventoryByProductId + ) { + for (UUID productId : requestedQuantities.keySet()) { + if (!inventoryByProductId.containsKey(productId)) { + throw new InventoryNotFoundException(productId); + } + } + } + + private void validateStockAvailability( + Map requestedQuantities, + Map inventoryByProductId + ) { + for (Map.Entry 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 buildOrderSpecification( + OrderStatus status, + LocalDate from, + LocalDate to + ) { + return (root, query, criteriaBuilder) -> { + var predicates = new java.util.ArrayList(); + + 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)); + }; + } +} diff --git a/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java b/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java new file mode 100644 index 0000000..243441f --- /dev/null +++ b/orders-backend/src/main/java/com/andwati/orders/services/ProductService.java @@ -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)); + } +} \ No newline at end of file