| 1 | package com.rbctcsworld.ecommerce.inventory; | |
| 2 | ||
| 3 | import com.rbctcsworld.ecommerce.common.exception.BusinessRuleException; | |
| 4 | import com.rbctcsworld.ecommerce.common.exception.ConflictException; | |
| 5 | import com.rbctcsworld.ecommerce.product.Product; | |
| 6 | import com.rbctcsworld.ecommerce.product.ProductRepository; | |
| 7 | import org.springframework.stereotype.Service; | |
| 8 | import org.springframework.transaction.annotation.Propagation; | |
| 9 | import org.springframework.transaction.annotation.Transactional; | |
| 10 | ||
| 11 | import java.util.List; | |
| 12 | ||
| 13 | /** | |
| 14 | * The ONLY place that changes product stock for orders. | |
| 15 | * | |
| 16 | * Overselling protection: the TOTAL is decreased with one conditional SQL statement | |
| 17 | * UPDATE products SET stock = stock - :qty WHERE id = :id AND active AND stock >= :qty | |
| 18 | * The database executes it atomically, so two customers buying the last unit at the same | |
| 19 | * moment cannot both succeed - one update affects 1 row, the other affects 0 rows. | |
| 20 | * A CHECK (stock >= 0) constraint is a second safety net. | |
| 21 | * | |
| 22 | * Warehouses: that same statement locks the product row, so allocating the units to warehouses | |
| 23 | * happens one order at a time per product. The warehouse rows are locked next (always product | |
| 24 | * first, then its warehouses: one lock order, no deadlocks), each warehouse is decreased with its | |
| 25 | * own conditional UPDATE (quantity >= :qty, plus CHECK quantity >= 0), and every allocation is | |
| 26 | * stored so cancel and return put the units back where they came from. | |
| 27 | * Invariant: products.stock = SUM(warehouse_stock.quantity) for every product. | |
| 28 | */ | |
| 29 | @Service | |
| 30 | @Transactional(propagation = Propagation.MANDATORY) // must run inside the caller's order transaction | |
| 31 | public class InventoryService { | |
| 32 | ||
| 33 | private final ProductRepository products; | |
| 34 | private final StockMovementRepository movements; | |
| 35 | private final WarehouseStore warehouses; | |
| 36 | ||
| 37 | public InventoryService(ProductRepository products, StockMovementRepository movements, WarehouseStore warehouses) { | |
| 38 | this.products = products; | |
| 39 | this.movements = movements; | |
| 40 | this.warehouses = warehouses; | |
| 41 | } | |
| 42 | ||
| 43 | /** Reserves stock for an order line or throws 409, shipping from the best warehouse (no state preference). */ | |
| 44 | public void reserve(Product product, int quantity, Long orderId) { | |
| 45 |
1
1. reserve : removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::reserve → KILLED |
reserve(product, quantity, orderId, null); |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Reserves stock for an order line or throws 409. Caller's transaction rolls back everything on failure. | |
| 50 | * shipState: the customer's state; a warehouse there is preferred (see {@link WarehouseAllocator}). | |
| 51 | */ | |
| 52 | public void reserve(Product product, int quantity, Long orderId, String shipState) { | |
| 53 | int updated = products.decrementStock(product.getId(), quantity); | |
| 54 |
1
1. reserve : removed conditional - replaced equality check with false → KILLED |
if (updated == 0) { |
| 55 |
1
1. reserve : removed conditional - replaced equality check with false → KILLED |
if (!product.isActive()) { |
| 56 | throw new ConflictException("Product no longer available: " + product.getSku()); | |
| 57 | } | |
| 58 | int available = products.currentStock(product.getId()); | |
| 59 | throw new ConflictException("Insufficient stock for " + product.getSku() | |
| 60 | + ": requested " + quantity + ", available " + available); | |
| 61 | } | |
| 62 | int after = products.currentStock(product.getId()); | |
| 63 | ||
| 64 | List<WarehouseAllocator.Allocation> plan = | |
| 65 | WarehouseAllocator.allocate(warehouses.lockStock(product.getId()), quantity, shipState); | |
| 66 |
1
1. reserve : removed conditional - replaced equality check with false → KILLED |
if (plan.isEmpty()) { |
| 67 | // the total said yes but the warehouses say no: the invariant is broken - never sell what is not on a shelf | |
| 68 | throw new ConflictException("Insufficient stock in the warehouses for " + product.getSku()); | |
| 69 | } | |
| 70 | int still = quantity; // units of this line not yet written as a movement | |
| 71 | for (WarehouseAllocator.Allocation a : plan) { | |
| 72 |
1
1. reserve : removed conditional - replaced equality check with false → KILLED |
if (!warehouses.take(a.warehouseId(), product.getId(), a.quantity())) { |
| 73 | throw new ConflictException("Insufficient stock in the warehouses for " + product.getSku()); | |
| 74 | } | |
| 75 |
1
1. reserve : removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::allocate → KILLED |
warehouses.allocate(orderId, product.getId(), a.warehouseId(), a.quantity()); |
| 76 |
1
1. reserve : Replaced integer subtraction with addition → KILLED |
still -= a.quantity(); |
| 77 |
2
1. reserve : removed negation → KILLED 2. reserve : Replaced integer addition with subtraction → KILLED |
movements.save(new StockMovement(product.getId(), orderId, a.warehouseId(), -a.quantity(), |
| 78 | StockMovement.ORDER_PLACED, after + still)); | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | /** Puts stock back when an order is cancelled. */ | |
| 83 | public void release(Long productId, int quantity, Long orderId) { | |
| 84 |
1
1. release : removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::release → KILLED |
release(productId, quantity, orderId, StockMovement.ORDER_CANCELLED); |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * Puts stock back with an explicit reason (ORDER_CANCELLED or RETURN_RESTOCK), into the warehouses | |
| 89 | * the order took it from. Orders from before warehouses existed go back to the default warehouse. | |
| 90 | */ | |
| 91 | public void release(Long productId, int quantity, Long orderId, String reason) { | |
| 92 | int before = products.currentStock(productId); | |
| 93 | products.incrementStock(productId, quantity); | |
| 94 | List<WarehouseAllocator.Allocation> taken = warehouses.allocations(orderId, productId); | |
| 95 |
1
1. release : removed conditional - replaced equality check with false → KILLED |
if (taken.isEmpty()) { |
| 96 | taken = List.of(new WarehouseAllocator.Allocation(warehouses.defaultWarehouse().id(), quantity)); | |
| 97 | } | |
| 98 | int running = before; | |
| 99 | for (WarehouseAllocator.Allocation a : taken) { | |
| 100 |
1
1. release : removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED |
warehouses.put(a.warehouseId(), productId, a.quantity()); |
| 101 |
1
1. release : Replaced integer addition with subtraction → KILLED |
running += a.quantity(); |
| 102 | movements.save(new StockMovement(productId, orderId, a.warehouseId(), a.quantity(), reason, running)); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | // ---------------------------------------------------------------- store admin | |
| 107 | ||
| 108 | /** A new product's first stock lands in the default warehouse. */ | |
| 109 | public void stockNewProduct(long productId, int quantity) { | |
| 110 |
3
1. stockNewProduct : removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED 2. stockNewProduct : removed conditional - replaced comparison check with false → KILLED 3. stockNewProduct : changed conditional boundary → KILLED |
if (quantity > 0) warehouses.put(warehouses.defaultWarehouse().id(), productId, quantity); |
| 111 | } | |
| 112 | ||
| 113 | /** | |
| 114 | * The admin typed a new total on the product form: the difference goes to (or comes from) the default | |
| 115 | * warehouse. Taking more than that warehouse holds is refused: the admin then corrects the stock per | |
| 116 | * warehouse instead. | |
| 117 | */ | |
| 118 | public void changeTotalFromProductForm(long productId, int newTotal) { | |
| 119 | int current = products.currentStock(productId); | |
| 120 |
1
1. changeTotalFromProductForm : Replaced integer subtraction with addition → KILLED |
int delta = newTotal - current; |
| 121 |
1
1. changeTotalFromProductForm : removed conditional - replaced equality check with false → KILLED |
if (delta == 0) return; |
| 122 | long home = warehouses.defaultWarehouse().id(); | |
| 123 |
2
1. changeTotalFromProductForm : changed conditional boundary → SURVIVED 2. changeTotalFromProductForm : removed conditional - replaced comparison check with false → KILLED |
if (delta > 0) { |
| 124 |
1
1. changeTotalFromProductForm : removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED |
warehouses.put(home, productId, delta); |
| 125 | products.incrementStock(productId, delta); | |
| 126 | } else { | |
| 127 |
2
1. changeTotalFromProductForm : removed negation → KILLED 2. changeTotalFromProductForm : removed conditional - replaced equality check with false → KILLED |
if (!warehouses.take(home, productId, -delta)) { |
| 128 | throw new BusinessRuleException("The stock is spread over several warehouses: lower it per warehouse " | |
| 129 | + "on the Warehouses page"); | |
| 130 | } | |
| 131 | products.incrementStock(productId, delta); | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
Mutations | ||
| 45 |
1.1 |
|
| 54 |
1.1 |
|
| 55 |
1.1 |
|
| 66 |
1.1 |
|
| 72 |
1.1 |
|
| 75 |
1.1 |
|
| 76 |
1.1 |
|
| 77 |
1.1 2.2 |
|
| 84 |
1.1 |
|
| 95 |
1.1 |
|
| 100 |
1.1 |
|
| 101 |
1.1 |
|
| 110 |
1.1 2.2 3.3 |
|
| 120 |
1.1 |
|
| 121 |
1.1 |
|
| 123 |
1.1 2.2 |
|
| 124 |
1.1 |
|
| 127 |
1.1 2.2 |