InventoryService.java

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
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveFailsWithAvailableQuantityInMessage()]
removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::reserve → KILLED

54

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveFailsWithAvailableQuantityInMessage()]
removed conditional - replaced equality check with false → KILLED

55

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveOfDeactivatedProductSaysNoLongerAvailable()]
removed conditional - replaced equality check with false → KILLED

66

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:whenTheWarehousesHoldLessThanTheTotalNothingIsSold()]
removed conditional - replaced equality check with false → KILLED

72

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:aWarehouseThatRefusesTheTakeStopsTheOrder()]
removed conditional - replaced equality check with false → KILLED

75

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveTakesFromTheBestWarehouseRecordsTheAllocationAndANegativeMovement()]
removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::allocate → KILLED

76

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveTakesFromTheBestWarehouseRecordsTheAllocationAndANegativeMovement()]
Replaced integer subtraction with addition → KILLED

77

1.1
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:reserveTakesFromTheBestWarehouseRecordsTheAllocationAndANegativeMovement()]
removed negation → KILLED

2.2
Location : reserve
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:aLineBiggerThanOneWarehouseIsSplitAndEachMovementShowsTheRunningTotal()]
Replaced integer addition with subtraction → KILLED

84

1.1
Location : release
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:anOrderFromBeforeWarehousesGoesBackToTheDefaultWarehouse()]
removed call to com/rbctcsworld/ecommerce/inventory/InventoryService::release → KILLED

95

1.1
Location : release
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:anOrderFromBeforeWarehousesGoesBackToTheDefaultWarehouse()]
removed conditional - replaced equality check with false → KILLED

100

1.1
Location : release
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:releasePutsUnitsBackWhereTheyCameFrom()]
removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED

101

1.1
Location : release
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:releasePutsUnitsBackWhereTheyCameFrom()]
Replaced integer addition with subtraction → KILLED

110

1.1
Location : stockNewProduct
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:aNewProductsFirstStockGoesToTheDefaultWarehouse()]
removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED

2.2
Location : stockNewProduct
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:aNewProductsFirstStockGoesToTheDefaultWarehouse()]
removed conditional - replaced comparison check with false → KILLED

3.3
Location : stockNewProduct
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:aNewProductsFirstStockGoesToTheDefaultWarehouse()]
changed conditional boundary → KILLED

120

1.1
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormChangesTheTotalThroughTheDefaultWarehouse()]
Replaced integer subtraction with addition → KILLED

121

1.1
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormChangesTheTotalThroughTheDefaultWarehouse()]
removed conditional - replaced equality check with false → KILLED

123

1.1
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormChangesTheTotalThroughTheDefaultWarehouse()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : changeTotalFromProductForm
Killed by : none
changed conditional boundary → SURVIVED
Covering tests

124

1.1
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormChangesTheTotalThroughTheDefaultWarehouse()]
removed call to com/rbctcsworld/ecommerce/inventory/WarehouseStore::put → KILLED

127

1.1
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormChangesTheTotalThroughTheDefaultWarehouse()]
removed negation → KILLED

2.2
Location : changeTotalFromProductForm
Killed by : com.rbctcsworld.ecommerce.inventory.InventoryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.inventory.InventoryServiceTest]/[method:theProductFormCannotTakeMoreThanTheDefaultWarehouseHolds()]
removed conditional - replaced equality check with false → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support