A comprehensive inventory management system for tracking stock levels, orders, sales, and suppliers across multiple warehouses and locations.
System Architecture Overview
Core Modules:
- Product Catalog - Manage products, categories, and variants
- Inventory Tracking - Real-time stock level monitoring
- Warehouse Management - Multi-location inventory
- Order Management - Purchase orders and sales orders
- Supplier Management - Vendor and supplier information
- Reporting & Analytics - Stock levels, movements, and forecasting
Database Schema & Dependencies
1. Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<lombok.version>1.18.28</lombok.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
2. Database Entities
@Entity
@Table(name = "products")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String sku;
@Column(nullable = false)
private String name;
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@Column(nullable = false)
private BigDecimal price;
private BigDecimal costPrice;
@Enumerated(EnumType.STRING)
private ProductStatus status;
private String imageUrl;
@Column(nullable = false)
private Integer weight; // in grams
@Column(nullable = false)
private Dimensions dimensions;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<ProductVariant> variants = new ArrayList<>();
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<InventoryItem> inventoryItems = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
@Embeddable
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Dimensions {
private Integer length; // mm
private Integer width; // mm
private Integer height; // mm
public Integer getVolume() {
return length * width * height;
}
}
@Entity
@Table(name = "categories")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "product_variants")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductVariant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private String variantSku;
@Column(nullable = false)
private String size;
private String color;
private String material;
@Column(nullable = false)
private BigDecimal priceAdjustment;
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "warehouses")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Warehouse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String code;
@Column(nullable = false)
private String name;
@Embedded
private Address address;
private String contactPhone;
private String contactEmail;
@Enumerated(EnumType.STRING)
private WarehouseStatus status;
@Column(nullable = false)
private Integer totalCapacity; // in cubic meters
private Integer usedCapacity;
@OneToMany(mappedBy = "warehouse")
private List<InventoryItem> inventoryItems = new ArrayList<>();
@OneToMany(mappedBy = "warehouse")
private List<WarehouseZone> zones = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "warehouse_zones")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WarehouseZone {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "warehouse_id", nullable = false)
private Warehouse warehouse;
@Column(nullable = false)
private String zoneCode; // e.g., "A-01", "B-02"
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
private ZoneType zoneType; // STORAGE, PICKING, RECEIVING, SHIPPING
private Integer capacity;
@OneToMany(mappedBy = "zone")
private List<InventoryItem> inventoryItems = new ArrayList<>();
}
@Entity
@Table(name = "inventory_items")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "warehouse_id", nullable = false)
private Warehouse warehouse;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "zone_id")
private WarehouseZone zone;
@Column(nullable = false)
private String batchNumber;
private LocalDate expiryDate;
@Column(nullable = false)
private Integer quantityOnHand;
@Column(nullable = false)
private Integer quantityReserved;
@Column(nullable = false)
private Integer quantityAvailable;
@Column(nullable = false)
private Integer minimumStockLevel;
@Column(nullable = false)
private Integer maximumStockLevel;
@Column(nullable = false)
private Integer reorderPoint;
@Enumerated(EnumType.STRING)
private StockStatus stockStatus;
@Version
private Long version; // For optimistic locking
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
@PreUpdate
@PrePersist
public void calculateQuantities() {
this.quantityAvailable = this.quantityOnHand - this.quantityReserved;
updateStockStatus();
}
private void updateStockStatus() {
if (quantityOnHand <= 0) {
this.stockStatus = StockStatus.OUT_OF_STOCK;
} else if (quantityOnHand <= reorderPoint) {
this.stockStatus = StockStatus.LOW_STOCK;
} else if (quantityOnHand >= maximumStockLevel) {
this.stockStatus = StockStatus.OVERSTOCKED;
} else {
this.stockStatus = StockStatus.IN_STOCK;
}
}
}
@Entity
@Table(name = "inventory_movements")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryMovement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "inventory_item_id", nullable = false)
private InventoryItem inventoryItem;
@Enumerated(EnumType.STRING)
private MovementType movementType;
@Column(nullable = false)
private Integer quantity;
private String referenceNumber; // PO number, Sales Order number, etc.
@Enumerated(EnumType.STRING)
private MovementReason reason;
private String notes;
@Column(nullable = false)
private Integer quantityBefore;
@Column(nullable = false)
private Integer quantityAfter;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "suppliers")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Supplier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String code;
@Column(nullable = false)
private String name;
@Embedded
private Address address;
private String contactPerson;
private String contactEmail;
private String contactPhone;
@Enumerated(EnumType.STRING)
private SupplierStatus status;
private String paymentTerms;
private Integer leadTimeDays;
@OneToMany(mappedBy = "supplier")
private List<PurchaseOrder> purchaseOrders = new ArrayList<>();
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "purchase_orders")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String poNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "supplier_id", nullable = false)
private Supplier supplier;
@Enumerated(EnumType.STRING)
private POStatus status;
@Column(nullable = false)
private LocalDate orderDate;
private LocalDate expectedDeliveryDate;
private LocalDate actualDeliveryDate;
@OneToMany(mappedBy = "purchaseOrder", cascade = CascadeType.ALL)
private List<PurchaseOrderItem> items = new ArrayList<>();
@Column(nullable = false)
private BigDecimal totalAmount;
private String notes;
@CreationTimestamp
private LocalDateTime createdAt;
}
@Entity
@Table(name = "purchase_order_items")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseOrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "purchase_order_id", nullable = false)
private PurchaseOrder purchaseOrder;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private Integer quantityOrdered;
@Column(nullable = false)
private Integer quantityReceived;
@Column(nullable = false)
private BigDecimal unitCost;
@Column(nullable = false)
private BigDecimal lineTotal;
}
// Enums
public enum ProductStatus {
ACTIVE, INACTIVE, DISCONTINUED
}
public enum WarehouseStatus {
ACTIVE, INACTIVE, MAINTENANCE
}
public enum ZoneType {
STORAGE, PICKING, RECEIVING, SHIPPING, QUARANTINE
}
public enum StockStatus {
IN_STOCK, LOW_STOCK, OUT_OF_STOCK, OVERSTOCKED
}
public enum MovementType {
INBOUND, OUTBOUND, TRANSFER, ADJUSTMENT
}
public enum MovementReason {
PURCHASE, SALE, RETURN, DAMAGED, EXPIRED, COUNT_ADJUSTMENT, TRANSFER
}
public enum SupplierStatus {
ACTIVE, INACTIVE, BLACKLISTED
}
public enum POStatus {
DRAFT, PENDING, APPROVED, ORDERED, PARTIALLY_RECEIVED, RECEIVED, CANCELLED
}
Core Services Implementation
1. Inventory Service
public interface InventoryService {
InventoryItem getInventoryItem(Long productId, Long warehouseId);
List<InventoryItem> getInventoryByProduct(Long productId);
List<InventoryItem> getInventoryByWarehouse(Long warehouseId);
InventoryMovement addStock(StockAdjustmentRequest request);
InventoryMovement removeStock(StockAdjustmentRequest request);
InventoryMovement transferStock(StockTransferRequest request);
List<InventoryItem> getLowStockItems();
List<InventoryItem> getOutOfStockItems();
InventorySummary getInventorySummary();
}
@Service
@Transactional
@Slf4j
public class InventoryServiceImpl implements InventoryService {
private final InventoryItemRepository inventoryItemRepository;
private final InventoryMovementRepository movementRepository;
private final ProductRepository productRepository;
private final WarehouseRepository warehouseRepository;
@Override
public InventoryMovement addStock(StockAdjustmentRequest request) {
InventoryItem item = getOrCreateInventoryItem(
request.getProductId(),
request.getWarehouseId(),
request.getZoneId()
);
// Optimistic locking check
if (!item.getVersion().equals(request.getVersion())) {
throw new OptimisticLockingFailureException("Inventory item was modified by another transaction");
}
int quantityBefore = item.getQuantityOnHand();
item.setQuantityOnHand(quantityBefore + request.getQuantity());
InventoryItem savedItem = inventoryItemRepository.save(item);
// Record movement
InventoryMovement movement = createMovement(
savedItem,
MovementType.INBOUND,
MovementReason.PURCHASE,
request.getQuantity(),
quantityBefore,
savedItem.getQuantityOnHand(),
request.getReferenceNumber(),
request.getNotes()
);
log.info("Stock added: Product {}, Warehouse {}, Quantity {}",
request.getProductId(), request.getWarehouseId(), request.getQuantity());
return movement;
}
@Override
public InventoryMovement removeStock(StockAdjustmentRequest request) {
InventoryItem item = inventoryItemRepository
.findByProductIdAndWarehouseId(request.getProductId(), request.getWarehouseId())
.orElseThrow(() -> new InventoryNotFoundException(
"Inventory item not found for product: " + request.getProductId() +
" in warehouse: " + request.getWarehouseId()));
if (!item.getVersion().equals(request.getVersion())) {
throw new OptimisticLockingFailureException("Inventory item was modified by another transaction");
}
if (item.getQuantityAvailable() < request.getQuantity()) {
throw new InsufficientStockException(
"Insufficient stock. Available: " + item.getQuantityAvailable() +
", Requested: " + request.getQuantity());
}
int quantityBefore = item.getQuantityOnHand();
item.setQuantityOnHand(quantityBefore - request.getQuantity());
InventoryItem savedItem = inventoryItemRepository.save(item);
InventoryMovement movement = createMovement(
savedItem,
MovementType.OUTBOUND,
MovementReason.SALE,
-request.getQuantity(),
quantityBefore,
savedItem.getQuantityOnHand(),
request.getReferenceNumber(),
request.getNotes()
);
log.info("Stock removed: Product {}, Warehouse {}, Quantity {}",
request.getProductId(), request.getWarehouseId(), request.getQuantity());
return movement;
}
@Override
public InventoryMovement transferStock(StockTransferRequest request) {
// Remove from source warehouse
StockAdjustmentRequest removeRequest = StockAdjustmentRequest.builder()
.productId(request.getProductId())
.warehouseId(request.getSourceWarehouseId())
.quantity(request.getQuantity())
.referenceNumber(request.getTransferNumber())
.notes("Transfer to warehouse: " + request.getDestinationWarehouseId())
.build();
InventoryMovement outboundMovement = removeStock(removeRequest);
// Add to destination warehouse
StockAdjustmentRequest addRequest = StockAdjustmentRequest.builder()
.productId(request.getProductId())
.warehouseId(request.getDestinationWarehouseId())
.zoneId(request.getDestinationZoneId())
.quantity(request.getQuantity())
.referenceNumber(request.getTransferNumber())
.notes("Transfer from warehouse: " + request.getSourceWarehouseId())
.build();
InventoryMovement inboundMovement = addStock(addRequest);
log.info("Stock transferred: Product {}, From {}, To {}, Quantity {}",
request.getProductId(), request.getSourceWarehouseId(),
request.getDestinationWarehouseId(), request.getQuantity());
return inboundMovement;
}
@Override
public List<InventoryItem> getLowStockItems() {
return inventoryItemRepository.findLowStockItems();
}
@Override
public List<InventoryItem> getOutOfStockItems() {
return inventoryItemRepository.findOutOfStockItems();
}
@Override
public InventorySummary getInventorySummary() {
return inventoryItemRepository.getInventorySummary();
}
private InventoryItem getOrCreateInventoryItem(Long productId, Long warehouseId, Long zoneId) {
return inventoryItemRepository
.findByProductIdAndWarehouseId(productId, warehouseId)
.orElseGet(() -> createNewInventoryItem(productId, warehouseId, zoneId));
}
private InventoryItem createNewInventoryItem(Long productId, Long warehouseId, Long zoneId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("Product not found: " + productId));
Warehouse warehouse = warehouseRepository.findById(warehouseId)
.orElseThrow(() -> new WarehouseNotFoundException("Warehouse not found: " + warehouseId));
WarehouseZone zone = null;
if (zoneId != null) {
// Fetch zone if provided
}
return InventoryItem.builder()
.product(product)
.warehouse(warehouse)
.zone(zone)
.quantityOnHand(0)
.quantityReserved(0)
.quantityAvailable(0)
.minimumStockLevel(10) // Default values
.maximumStockLevel(1000)
.reorderPoint(25)
.stockStatus(StockStatus.OUT_OF_STOCK)
.batchNumber("BATCH-" + System.currentTimeMillis())
.build();
}
private InventoryMovement createMovement(InventoryItem item, MovementType type,
MovementReason reason, Integer quantity,
Integer quantityBefore, Integer quantityAfter,
String reference, String notes) {
InventoryMovement movement = InventoryMovement.builder()
.inventoryItem(item)
.movementType(type)
.movementReason(reason)
.quantity(quantity)
.quantityBefore(quantityBefore)
.quantityAfter(quantityAfter)
.referenceNumber(reference)
.notes(notes)
.build();
return movementRepository.save(movement);
}
}
2. Purchase Order Service
@Service
@Transactional
@Slf4j
public class PurchaseOrderService {
private final PurchaseOrderRepository poRepository;
private final PurchaseOrderItemRepository poItemRepository;
private final SupplierRepository supplierRepository;
private final ProductRepository productRepository;
private final InventoryService inventoryService;
public PurchaseOrder createPurchaseOrder(CreatePurchaseOrderRequest request) {
Supplier supplier = supplierRepository.findById(request.getSupplierId())
.orElseThrow(() -> new SupplierNotFoundException("Supplier not found: " + request.getSupplierId()));
PurchaseOrder po = PurchaseOrder.builder()
.poNumber(generatePONumber())
.supplier(supplier)
.status(POStatus.DRAFT)
.orderDate(LocalDate.now())
.expectedDeliveryDate(request.getExpectedDeliveryDate())
.totalAmount(BigDecimal.ZERO)
.notes(request.getNotes())
.build();
List<PurchaseOrderItem> items = new ArrayList<>();
BigDecimal totalAmount = BigDecimal.ZERO;
for (PurchaseOrderItemRequest itemRequest : request.getItems()) {
Product product = productRepository.findById(itemRequest.getProductId())
.orElseThrow(() -> new ProductNotFoundException("Product not found: " + itemRequest.getProductId()));
BigDecimal lineTotal = itemRequest.getUnitCost().multiply(
BigDecimal.valueOf(itemRequest.getQuantity()));
PurchaseOrderItem item = PurchaseOrderItem.builder()
.purchaseOrder(po)
.product(product)
.quantityOrdered(itemRequest.getQuantity())
.quantityReceived(0)
.unitCost(itemRequest.getUnitCost())
.lineTotal(lineTotal)
.build();
items.add(item);
totalAmount = totalAmount.add(lineTotal);
}
po.setItems(items);
po.setTotalAmount(totalAmount);
return poRepository.save(po);
}
public PurchaseOrder approvePurchaseOrder(Long poId) {
PurchaseOrder po = poRepository.findById(poId)
.orElseThrow(() -> new PurchaseOrderNotFoundException("Purchase order not found: " + poId));
if (po.getStatus() != POStatus.DRAFT && po.getStatus() != POStatus.PENDING) {
throw new InvalidOperationException("Only DRAFT or PENDING orders can be approved");
}
po.setStatus(POStatus.APPROVED);
return poRepository.save(po);
}
public PurchaseOrder receivePurchaseOrder(Long poId, List<ReceiveItemRequest> receivedItems) {
PurchaseOrder po = poRepository.findByIdWithItems(poId)
.orElseThrow(() -> new PurchaseOrderNotFoundException("Purchase order not found: " + poId));
if (po.getStatus() != POStatus.APPROVED && po.getStatus() != POStatus.ORDERED) {
throw new InvalidOperationException("Only APPROVED or ORDERED orders can be received");
}
boolean fullyReceived = true;
for (ReceiveItemRequest receivedItem : receivedItems) {
PurchaseOrderItem poItem = po.getItems().stream()
.filter(item -> item.getId().equals(receivedItem.getPoItemId()))
.findFirst()
.orElseThrow(() -> new PurchaseOrderItemNotFoundException(
"Purchase order item not found: " + receivedItem.getPoItemId()));
int totalReceived = poItem.getQuantityReceived() + receivedItem.getQuantityReceived();
if (totalReceived > poItem.getQuantityOrdered()) {
throw new InvalidOperationException(
"Received quantity exceeds ordered quantity for item: " + poItem.getId());
}
poItem.setQuantityReceived(totalReceived);
// Add to inventory
if (receivedItem.getQuantityReceived() > 0) {
StockAdjustmentRequest stockRequest = StockAdjustmentRequest.builder()
.productId(poItem.getProduct().getId())
.warehouseId(receivedItem.getWarehouseId())
.zoneId(receivedItem.getZoneId())
.quantity(receivedItem.getQuantityReceived())
.referenceNumber(po.getPoNumber())
.notes("Purchase order receipt")
.build();
inventoryService.addStock(stockRequest);
}
if (totalReceived < poItem.getQuantityOrdered()) {
fullyReceived = false;
}
}
po.setStatus(fullyReceived ? POStatus.RECEIVED : POStatus.PARTIALLY_RECEIVED);
po.setActualDeliveryDate(LocalDate.now());
return poRepository.save(po);
}
public List<PurchaseOrder> getPendingOrders() {
return poRepository.findByStatusIn(List.of(POStatus.PENDING, POStatus.APPROVED, POStatus.ORDERED));
}
private String generatePONumber() {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String random = String.valueOf(ThreadLocalRandom.current().nextInt(1000, 9999));
return "PO-" + timestamp + "-" + random;
}
}
3. Stock Reservation Service
@Service
@Transactional
@Slf4j
public class StockReservationService {
private final InventoryItemRepository inventoryItemRepository;
private final StockReservationRepository reservationRepository;
public StockReservation reserveStock(ReserveStockRequest request) {
// Find available inventory across warehouses
List<InventoryItem> availableItems = inventoryItemRepository
.findByProductIdAndQuantityAvailableGreaterThanEqualOrderByQuantityAvailableDesc(
request.getProductId(), request.getQuantity());
if (availableItems.isEmpty()) {
throw new InsufficientStockException("Insufficient stock for product: " + request.getProductId());
}
int remainingQuantity = request.getQuantity();
List<StockReservation> reservations = new ArrayList<>();
for (InventoryItem item : availableItems) {
if (remainingQuantity <= 0) break;
int reservableQuantity = Math.min(item.getQuantityAvailable(), remainingQuantity);
// Create reservation
StockReservation reservation = StockReservation.builder()
.inventoryItem(item)
.orderNumber(request.getOrderNumber())
.quantityReserved(reservableQuantity)
.expiresAt(LocalDateTime.now().plusHours(24)) // 24-hour reservation
.status(ReservationStatus.ACTIVE)
.build();
reservations.add(reservationRepository.save(reservation));
// Update inventory reserved quantity
item.setQuantityReserved(item.getQuantityReserved() + reservableQuantity);
inventoryItemRepository.save(item);
remainingQuantity -= reservableQuantity;
}
if (remainingQuantity > 0) {
// Cancel all reservations if we couldn't reserve full quantity
reservations.forEach(this::cancelReservation);
throw new InsufficientStockException("Could not reserve full quantity. Only reserved: " +
(request.getQuantity() - remainingQuantity));
}
return reservations.get(0); // Return first reservation as reference
}
public void cancelReservation(Long reservationId) {
StockReservation reservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new ReservationNotFoundException("Reservation not found: " + reservationId));
cancelReservation(reservation);
}
public void cancelReservation(StockReservation reservation) {
if (reservation.getStatus() == ReservationStatus.CANCELLED) {
return;
}
InventoryItem item = reservation.getInventoryItem();
item.setQuantityReserved(item.getQuantityReserved() - reservation.getQuantityReserved());
inventoryItemRepository.save(item);
reservation.setStatus(ReservationStatus.CANCELLED);
reservationRepository.save(reservation);
log.info("Stock reservation cancelled: {}", reservation.getId());
}
public void commitReservation(Long reservationId) {
StockReservation reservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new ReservationNotFoundException("Reservation not found: " + reservationId));
if (reservation.getStatus() != ReservationStatus.ACTIVE) {
throw new InvalidOperationException("Only active reservations can be committed");
}
// Mark reservation as committed
reservation.setStatus(ReservationStatus.COMMITTED);
reservationRepository.save(reservation);
log.info("Stock reservation committed: {}", reservation.getId());
}
@Scheduled(fixedRate = 3600000) // Run every hour
public void cleanupExpiredReservations() {
List<StockReservation> expiredReservations = reservationRepository
.findExpiredReservations(LocalDateTime.now());
for (StockReservation reservation : expiredReservations) {
cancelReservation(reservation);
log.info("Expired reservation cleaned up: {}", reservation.getId());
}
}
}
4. Reporting Service
@Service
@Transactional(readOnly = true)
@Slf4j
public class InventoryReportingService {
private final InventoryItemRepository inventoryItemRepository;
private final InventoryMovementRepository movementRepository;
private final PurchaseOrderRepository poRepository;
public InventoryReport generateInventoryReport(ReportRequest request) {
List<InventoryItem> items = inventoryItemRepository.findAll();
InventoryReport report = new InventoryReport();
report.setGeneratedAt(LocalDateTime.now());
report.setTotalProducts(items.stream().map(InventoryItem::getProduct).distinct().count());
report.setTotalValue(calculateTotalInventoryValue(items));
report.setLowStockCount(countLowStockItems(items));
report.setOutOfStockCount(countOutOfStockItems(items));
// Group by warehouse
Map<String, InventorySummary> warehouseSummary = items.stream()
.collect(Collectors.groupingBy(
item -> item.getWarehouse().getName(),
Collectors.collectingAndThen(Collectors.toList(), this::createWarehouseSummary)
));
report.setWarehouseSummary(warehouseSummary);
return report;
}
public MovementReport generateMovementReport(MovementReportRequest request) {
List<InventoryMovement> movements = movementRepository.findByCreatedAtBetween(
request.getStartDate().atStartOfDay(),
request.getEndDate().atTime(23, 59, 59)
);
MovementReport report = new MovementReport();
report.setPeriodStart(request.getStartDate());
report.setPeriodEnd(request.getEndDate());
report.setTotalMovements(movements.size());
// Group by movement type
Map<MovementType, Long> movementsByType = movements.stream()
.collect(Collectors.groupingBy(InventoryMovement::getMovementType, Collectors.counting()));
report.setMovementsByType(movementsByType);
// Calculate net change
int netChange = movements.stream()
.mapToInt(InventoryMovement::getQuantity)
.sum();
report.setNetQuantityChange(netChange);
return report;
}
public List<ReplenishmentRecommendation> getReplenishmentRecommendations() {
List<InventoryItem> lowStockItems = inventoryItemRepository.findLowStockItems();
return lowStockItems.stream()
.map(this::createReplenishmentRecommendation)
.collect(Collectors.toList());
}
private InventorySummary createWarehouseSummary(List<InventoryItem> items) {
BigDecimal totalValue = items.stream()
.map(item -> item.getProduct().getPrice().multiply(BigDecimal.valueOf(item.getQuantityOnHand())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
long lowStockCount = items.stream()
.filter(item -> item.getStockStatus() == StockStatus.LOW_STOCK)
.count();
long outOfStockCount = items.stream()
.filter(item -> item.getStockStatus() == StockStatus.OUT_OF_STOCK)
.count();
return InventorySummary.builder()
.totalItems(items.size())
.totalValue(totalValue)
.lowStockCount(lowStockCount)
.outOfStockCount(outOfStockCount)
.build();
}
private ReplenishmentRecommendation createReplenishmentRecommendation(InventoryItem item) {
int recommendedQuantity = item.getMaximumStockLevel() - item.getQuantityOnHand();
ReplenishmentPriority priority = calculateReplenishmentPriority(item);
return ReplenishmentRecommendation.builder()
.product(item.getProduct())
.warehouse(item.getWarehouse())
.currentStock(item.getQuantityOnHand())
.reorderPoint(item.getReorderPoint())
.recommendedQuantity(recommendedQuantity)
.priority(priority)
.urgency(getUrgencyLevel(item))
.build();
}
private ReplenishmentPriority calculateReplenishmentPriority(InventoryItem item) {
double stockRatio = (double) item.getQuantityOnHand() / item.getReorderPoint();
if (stockRatio <= 0.1) return ReplenishmentPriority.CRITICAL;
if (stockRatio <= 0.3) return ReplenishmentPriority.HIGH;
if (stockRatio <= 0.6) return ReplenishmentPriority.MEDIUM;
return ReplenishmentPriority.LOW;
}
private String getUrgencyLevel(InventoryItem item) {
int daysOfStock = (int) (item.getQuantityOnHand() / getAverageDailyUsage(item.getProduct().getId()));
if (daysOfStock <= 1) return "IMMEDIATE";
if (daysOfStock <= 3) return "URGENT";
if (daysOfStock <= 7) return "SOON";
return "NORMAL";
}
private double getAverageDailyUsage(Long productId) {
// Implementation to calculate average daily sales/usage
return 10.0; // Default value
}
private BigDecimal calculateTotalInventoryValue(List<InventoryItem> items) {
return items.stream()
.map(item -> item.getProduct().getPrice().multiply(BigDecimal.valueOf(item.getQuantityOnHand())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private long countLowStockItems(List<InventoryItem> items) {
return items.stream()
.filter(item -> item.getStockStatus() == StockStatus.LOW_STOCK)
.count();
}
private long countOutOfStockItems(List<InventoryItem> items) {
return items.stream()
.filter(item -> item.getStockStatus() == StockStatus.OUT_OF_STOCK)
.count();
}
}
REST Controllers
1. Inventory Controller
@RestController
@RequestMapping("/api/inventory")
@Validated
@Slf4j
public class InventoryController {
private final InventoryService inventoryService;
private final StockReservationService reservationService;
@PostMapping("/stock/add")
public ResponseEntity<InventoryMovement> addStock(@Valid @RequestBody StockAdjustmentRequest request) {
InventoryMovement movement = inventoryService.addStock(request);
return ResponseEntity.ok(movement);
}
@PostMapping("/stock/remove")
public ResponseEntity<InventoryMovement> removeStock(@Valid @RequestBody StockAdjustmentRequest request) {
InventoryMovement movement = inventoryService.removeStock(request);
return ResponseEntity.ok(movement);
}
@PostMapping("/stock/transfer")
public ResponseEntity<InventoryMovement> transferStock(@Valid @RequestBody StockTransferRequest request) {
InventoryMovement movement = inventoryService.transferStock(request);
return ResponseEntity.ok(movement);
}
@PostMapping("/reservations")
public ResponseEntity<StockReservation> reserveStock(@Valid @RequestBody ReserveStockRequest request) {
StockReservation reservation = reservationService.reserveStock(request);
return ResponseEntity.ok(reservation);
}
@DeleteMapping("/reservations/{reservationId}")
public ResponseEntity<Void> cancelReservation(@PathVariable Long reservationId) {
reservationService.cancelReservation(reservationId);
return ResponseEntity.noContent().build();
}
@GetMapping("/products/{productId}/stock")
public ResponseEntity<List<InventoryItem>> getProductInventory(@PathVariable Long productId) {
List<InventoryItem> inventory = inventoryService.getInventoryByProduct(productId);
return ResponseEntity.ok(inventory);
}
@GetMapping("/warehouses/{warehouseId}/stock")
public ResponseEntity<List<InventoryItem>> getWarehouseInventory(@PathVariable Long warehouseId) {
List<InventoryItem> inventory = inventoryService.getInventoryByWarehouse(warehouseId);
return ResponseEntity.ok(inventory);
}
@GetMapping("/alerts/low-stock")
public ResponseEntity<List<InventoryItem>> getLowStockAlerts() {
List<InventoryItem> lowStockItems = inventoryService.getLowStockItems();
return ResponseEntity.ok(lowStockItems);
}
@GetMapping("/alerts/out-of-stock")
public ResponseEntity<List<InventoryItem>> getOutOfStockAlerts() {
List<InventoryItem> outOfStockItems = inventoryService.getOutOfStockItems();
return ResponseEntity.ok(outOfStockItems);
}
}
2. Purchase Order Controller
@RestController
@RequestMapping("/api/purchase-orders")
@Validated
@Slf4j
public class PurchaseOrderController {
private final PurchaseOrderService purchaseOrderService;
@PostMapping
public ResponseEntity<PurchaseOrder> createPurchaseOrder(
@Valid @RequestBody CreatePurchaseOrderRequest request) {
PurchaseOrder po = purchaseOrderService.createPurchaseOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(po);
}
@PutMapping("/{poId}/approve")
public ResponseEntity<PurchaseOrder> approvePurchaseOrder(@PathVariable Long poId) {
PurchaseOrder po = purchaseOrderService.approvePurchaseOrder(poId);
return ResponseEntity.ok(po);
}
@PutMapping("/{poId}/receive")
public ResponseEntity<PurchaseOrder> receivePurchaseOrder(
@PathVariable Long poId,
@Valid @RequestBody List<ReceiveItemRequest> receivedItems) {
PurchaseOrder po = purchaseOrderService.receivePurchaseOrder(poId, receivedItems);
return ResponseEntity.ok(po);
}
@GetMapping("/pending")
public ResponseEntity<List<PurchaseOrder>> getPendingOrders() {
List<PurchaseOrder> orders = purchaseOrderService.getPendingOrders();
return ResponseEntity.ok(orders);
}
@GetMapping("/{poId}")
public ResponseEntity<PurchaseOrder> getPurchaseOrder(@PathVariable Long poId) {
PurchaseOrder po = purchaseOrderService.getPurchaseOrder(poId);
return ResponseEntity.ok(po);
}
}
3. Reporting Controller
@RestController
@RequestMapping("/api/reports")
@Slf4j
public class ReportingController {
private final InventoryReportingService reportingService;
@GetMapping("/inventory")
public ResponseEntity<InventoryReport> generateInventoryReport(
@RequestParam(defaultValue = "false") boolean includeDetails) {
ReportRequest request = new ReportRequest(includeDetails);
InventoryReport report = reportingService.generateInventoryReport(request);
return ResponseEntity.ok(report);
}
@GetMapping("/movements")
public ResponseEntity<MovementReport> generateMovementReport(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
MovementReportRequest request = new MovementReportRequest(startDate, endDate);
MovementReport report = reportingService.generateMovementReport(request);
return ResponseEntity.ok(report);
}
@GetMapping("/replenishment")
public ResponseEntity<List<ReplenishmentRecommendation>> getReplenishmentRecommendations() {
List<ReplenishmentRecommendation> recommendations =
reportingService.getReplenishmentRecommendations();
return ResponseEntity.ok(recommendations);
}
}
Configuration & Cache
1. Cache Configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
cacheManager.setCacheNames(Arrays.asList(
"products",
"inventory",
"warehouses",
"categories",
"suppliers"
));
return cacheManager;
}
}
2. Application Configuration
@Configuration
@EnableAsync
@EnableScheduling
public class AppConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("inventory-");
return executor;
}
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> {
builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
builder.serializers(new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE));
};
}
}
Testing
1. Service Unit Tests
@ExtendWith(MockitoExtension.class)
class InventoryServiceTest {
@Mock
private InventoryItemRepository inventoryItemRepository;
@Mock
private InventoryMovementRepository movementRepository;
@Mock
private ProductRepository productRepository;
@Mock
private WarehouseRepository warehouseRepository;
@InjectMocks
private InventoryServiceImpl inventoryService;
@Test
void whenAddStock_thenInventoryUpdated() {
// Given
Long productId = 1L;
Long warehouseId = 1L;
StockAdjustmentRequest request = StockAdjustmentRequest.builder()
.productId(productId)
.warehouseId(warehouseId)
.quantity(10)
.build();
InventoryItem existingItem = InventoryItem.builder()
.id(1L)
.quantityOnHand(5)
.quantityReserved(0)
.version(1L)
.build();
when(inventoryItemRepository.findByProductIdAndWarehouseId(productId, warehouseId))
.thenReturn(Optional.of(existingItem));
// When
inventoryService.addStock(request);
// Then
verify(inventoryItemRepository).save(argThat(item ->
item.getQuantityOnHand() == 15));
verify(movementRepository).save(any(InventoryMovement.class));
}
@Test
void whenInsufficientStock_thenThrowException() {
// Given
StockAdjustmentRequest request = StockAdjustmentRequest.builder()
.productId(1L)
.warehouseId(1L)
.quantity(20)
.build();
InventoryItem item = InventoryItem.builder()
.quantityOnHand(5)
.quantityReserved(0)
.quantityAvailable(5)
.version(1L)
.build();
when(inventoryItemRepository.findByProductIdAndWarehouseId(any(), any()))
.thenReturn(Optional.of(item));
// When & Then
assertThrows(InsufficientStockException.class, () -> {
inventoryService.removeStock(request);
});
}
}
Best Practices Implemented
- Optimistic Locking: Prevent race conditions with
@Version - Caching: Improve performance for frequently accessed data
- Async Processing: Handle background tasks efficiently
- Comprehensive Logging: Track all inventory movements
- Validation: Ensure data integrity with Bean Validation
- Error Handling: Custom exceptions for business logic errors
- Scheduled Tasks: Automated cleanup and maintenance
- Reporting: Comprehensive analytics and insights
This inventory management system provides a robust foundation that can scale from small businesses to enterprise-level operations with multiple warehouses, complex product catalogs, and sophisticated reporting requirements.
Java Observability, Logging Intelligence & AI-Driven Monitoring (APM, Tracing, Logs & Anomaly Detection)
https://macronepal.com/blog/beyond-metrics-observing-serverless-and-traditional-java-applications-with-thundra-apm/
Explains using Thundra APM to observe both serverless and traditional Java applications by combining tracing, metrics, and logs into a unified observability platform for faster debugging and performance insights.
https://macronepal.com/blog/dynatrace-oneagent-in-java-2/
Explains Dynatrace OneAgent for Java, which automatically instruments JVM applications to capture metrics, traces, and logs, enabling full-stack monitoring and root-cause analysis with minimal configuration.
https://macronepal.com/blog/lightstep-java-sdk-distributed-tracing-and-observability-implementation/
Explains Lightstep Java SDK for distributed tracing, helping developers track requests across microservices and identify latency issues using OpenTelemetry-based observability.
https://macronepal.com/blog/honeycomb-io-beeline-for-java-complete-guide-2/
Explains Honeycomb Beeline for Java, which provides high-cardinality observability and deep query capabilities to understand complex system behavior and debug distributed systems efficiently.
https://macronepal.com/blog/lumigo-for-serverless-in-java-complete-distributed-tracing-guide-2/
Explains Lumigo for Java serverless applications, offering automatic distributed tracing, log correlation, and error tracking to simplify debugging in cloud-native environments. (Lumigo Docs)
https://macronepal.com/blog/from-noise-to-signals-implementing-log-anomaly-detection-in-java-applications/
Explains how to detect anomalies in Java logs using behavioral patterns and machine learning techniques to separate meaningful incidents from noisy log data and improve incident response.
https://macronepal.com/blog/ai-powered-log-analysis-in-java-from-reactive-debugging-to-proactive-insights/
Explains AI-driven log analysis for Java applications, shifting from manual debugging to predictive insights that identify issues early and improve system reliability using intelligent log processing.
https://macronepal.com/blog/titliel-java-logging-best-practices/
Explains best practices for Java logging, focusing on structured logs, proper log levels, performance optimization, and ensuring logs are useful for debugging and observability systems.
https://macronepal.com/blog/seeking-a-loguru-for-java-the-quest-for-elegant-and-simple-logging/
Explains the search for simpler, more elegant logging frameworks in Java, comparing modern logging approaches that aim to reduce complexity while improving readability and developer experience.