InventoryService.java
package com.rbctcsworld.ecommerce.inventory;
import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException;
import com.rbctcsworld.ecommerce.common.exception.ConflictException;
import com.rbctcsworld.ecommerce.product.Product;
import com.rbctcsworld.ecommerce.product.ProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* The ONLY place that changes product stock for orders.
*
* Overselling protection: the TOTAL is decreased with one conditional SQL statement
* UPDATE products SET stock = stock - :qty WHERE id = :id AND active AND stock >= :qty
* The database executes it atomically, so two customers buying the last unit at the same
* moment cannot both succeed - one update affects 1 row, the other affects 0 rows.
* A CHECK (stock >= 0) constraint is a second safety net.
*
* Warehouses: that same statement locks the product row, so allocating the units to warehouses
* happens one order at a time per product. The warehouse rows are locked next (always product
* first, then its warehouses: one lock order, no deadlocks), each warehouse is decreased with its
* own conditional UPDATE (quantity >= :qty, plus CHECK quantity >= 0), and every allocation is
* stored so cancel and return put the units back where they came from.
* Invariant: products.stock = SUM(warehouse_stock.quantity) for every product.
*/
@Service
@Transactional(propagation = Propagation.MANDATORY) // must run inside the caller's order transaction
public class InventoryService {
private final ProductRepository products;
private final StockMovementRepository movements;
private final WarehouseStore warehouses;
public InventoryService(ProductRepository products, StockMovementRepository movements, WarehouseStore warehouses) {
this.products = products;
this.movements = movements;
this.warehouses = warehouses;
}
/** Reserves stock for an order line or throws 409, shipping from the best warehouse (no state preference). */
public void reserve(Product product, int quantity, Long orderId) {
reserve(product, quantity, orderId, null);
}
/**
* Reserves stock for an order line or throws 409. Caller's transaction rolls back everything on failure.
* shipState: the customer's state; a warehouse there is preferred (see {@link WarehouseAllocator}).
*/
public void reserve(Product product, int quantity, Long orderId, String shipState) {
int updated = products.decrementStock(product.getId(), quantity);
if (updated == 0) {
if (!product.isActive()) {
throw new ConflictException("Product no longer available: " + product.getSku());
}
int available = products.currentStock(product.getId());
throw new ConflictException("Insufficient stock for " + product.getSku()
+ ": requested " + quantity + ", available " + available);
}
int after = products.currentStock(product.getId());
List<WarehouseAllocator.Allocation> plan =
WarehouseAllocator.allocate(warehouses.lockStock(product.getId()), quantity, shipState);
if (plan.isEmpty()) {
// the total said yes but the warehouses say no: the invariant is broken - never sell what is not on a shelf
throw new ConflictException("Insufficient stock in the warehouses for " + product.getSku());
}
int still = quantity; // units of this line not yet written as a movement
for (WarehouseAllocator.Allocation a : plan) {
if (!warehouses.take(a.warehouseId(), product.getId(), a.quantity())) {
throw new ConflictException("Insufficient stock in the warehouses for " + product.getSku());
}
warehouses.allocate(orderId, product.getId(), a.warehouseId(), a.quantity());
still -= a.quantity();
movements.save(new StockMovement(product.getId(), orderId, a.warehouseId(), -a.quantity(),
StockMovement.ORDER_PLACED, after + still));
}
}
/** Puts stock back when an order is cancelled. */
public void release(Long productId, int quantity, Long orderId) {
release(productId, quantity, orderId, StockMovement.ORDER_CANCELLED);
}
/**
* Puts stock back with an explicit reason (ORDER_CANCELLED or RETURN_RESTOCK), into the warehouses
* the order took it from. Orders from before warehouses existed go back to the default warehouse.
*/
public void release(Long productId, int quantity, Long orderId, String reason) {
int before = products.currentStock(productId);
products.incrementStock(productId, quantity);
List<WarehouseAllocator.Allocation> taken = warehouses.allocations(orderId, productId);
if (taken.isEmpty()) {
taken = List.of(new WarehouseAllocator.Allocation(warehouses.defaultWarehouse().id(), quantity));
}
int running = before;
for (WarehouseAllocator.Allocation a : taken) {
warehouses.put(a.warehouseId(), productId, a.quantity());
running += a.quantity();
movements.save(new StockMovement(productId, orderId, a.warehouseId(), a.quantity(), reason, running));
}
}
// ---------------------------------------------------------------- store admin
/** A new product's first stock lands in the default warehouse. */
public void stockNewProduct(long productId, int quantity) {
if (quantity > 0) warehouses.put(warehouses.defaultWarehouse().id(), productId, quantity);
}
/**
* The admin typed a new total on the product form: the difference goes to (or comes from) the default
* warehouse. Taking more than that warehouse holds is refused: the admin then corrects the stock per
* warehouse instead.
*/
public void changeTotalFromProductForm(long productId, int newTotal) {
int current = products.currentStock(productId);
int delta = newTotal - current;
if (delta == 0) return;
long home = warehouses.defaultWarehouse().id();
if (delta > 0) {
warehouses.put(home, productId, delta);
products.incrementStock(productId, delta);
} else {
if (!warehouses.take(home, productId, -delta)) {
throw new BusinessRuleException("The stock is spread over several warehouses: lower it per warehouse "
+ "on the Warehouses page");
}
products.incrementStock(productId, delta);
}
}
}