mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:29:35 +01:00
feat(inventory): Bestand reservieren mit FEFO-Allokation (#12)
Implementiert Story 4.1: Reservierung von Beständen mit automatischer
FEFO-Allokation (First-Expired-First-Out) über verfügbare Chargen.
Domain: Reservation-Entity, StockBatchAllocation, ReservationDraft,
FEFO-Logik in Stock.reserve(), availableQuantity() berücksichtigt
bestehende Allokationen. Neue Error-Varianten für InsufficientStock,
InvalidReferenceType, InvalidReservationPriority, ReservationNotFound.
API: POST /api/inventory/stocks/{stockId}/reservations → 201 Created.
Liquibase: reservations + stock_batch_allocations Tabellen mit FK- und
CHECK-Constraints.
Tests: 43 neue Tests (22 Domain, 10 UseCase, 11 Integration) für
FEFO-Logik, Validierung, Mengenprüfung, Auth und Edge Cases.
This commit is contained in:
parent
b77b209f10
commit
0b49bb2977
38 changed files with 1656 additions and 57 deletions
|
|
@ -91,6 +91,7 @@ classDiagram
|
|||
}
|
||||
|
||||
class StockBatchAllocation {
|
||||
+AllocationId id
|
||||
+StockBatchId stockBatchId
|
||||
+Quantity allocatedQuantity
|
||||
}
|
||||
|
|
@ -163,6 +164,8 @@ classDiagram
|
|||
StockBatch ..> StockBatchStatus
|
||||
StockBatch ..> Quantity
|
||||
|
||||
StockBatchAllocation ..> AllocationId
|
||||
|
||||
StockMovement ..> StockMovementId
|
||||
StockMovement ..> MovementType
|
||||
StockMovement ..> MovementDirection
|
||||
|
|
@ -208,6 +211,7 @@ Stock (Aggregate Root)
|
|||
├── Priority (ReservationPriority: URGENT | NORMAL | LOW)
|
||||
├── ReservedAt (Instant)
|
||||
└── StockBatchAllocations[] (Entity)
|
||||
├── AllocationId (VO)
|
||||
├── StockBatchId (VO) - Zugeordnete Charge
|
||||
└── AllocatedQuantity (VO) - Reservierte Menge aus dieser Charge
|
||||
```
|
||||
|
|
@ -846,6 +850,15 @@ public sealed interface StockError {
|
|||
record NegativeStockNotAllowed() implements StockError {
|
||||
public String message() { return "Stock cannot go negative"; }
|
||||
}
|
||||
record InvalidReferenceType(String value) implements StockError {
|
||||
public String message() { return "Invalid reference type: " + value; }
|
||||
}
|
||||
record InvalidReferenceId(String reason) implements StockError {
|
||||
public String message() { return "Invalid reference ID: " + reason; }
|
||||
}
|
||||
record InvalidReservationPriority(String value) implements StockError {
|
||||
public String message() { return "Invalid reservation priority: " + value; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed interface StockMovementError {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class ReserveStock {
|
||||
|
||||
private final StockRepository stockRepository;
|
||||
|
||||
public ReserveStock(StockRepository stockRepository) {
|
||||
this.stockRepository = stockRepository;
|
||||
}
|
||||
|
||||
public Result<StockError, Reservation> execute(ReserveStockCommand cmd) {
|
||||
// 1. Stock laden
|
||||
if (cmd.stockId() == null || cmd.stockId().isBlank()) {
|
||||
return Result.failure(new StockError.StockNotFound("null"));
|
||||
}
|
||||
|
||||
Stock stock;
|
||||
switch (stockRepository.findById(StockId.of(cmd.stockId()))) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var opt) -> {
|
||||
if (opt.isEmpty()) {
|
||||
return Result.failure(new StockError.StockNotFound(cmd.stockId()));
|
||||
}
|
||||
stock = opt.get();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Draft erstellen
|
||||
var draft = new ReservationDraft(
|
||||
cmd.referenceType(), cmd.referenceId(),
|
||||
cmd.quantityAmount(), cmd.quantityUnit(),
|
||||
cmd.priority()
|
||||
);
|
||||
|
||||
// 3. Domain-Methode aufrufen
|
||||
Reservation reservation;
|
||||
switch (stock.reserve(draft)) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> reservation = val;
|
||||
}
|
||||
|
||||
// 4. Speichern
|
||||
switch (stockRepository.save(stock)) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
return Result.success(reservation);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record ReserveStockCommand(
|
||||
String stockId,
|
||||
String referenceType,
|
||||
String referenceId,
|
||||
String quantityAmount,
|
||||
String quantityUnit,
|
||||
String priority
|
||||
) {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record AllocationId(String value) {
|
||||
|
||||
public AllocationId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("AllocationId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static AllocationId generate() {
|
||||
return new AllocationId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static AllocationId of(String value) {
|
||||
return new AllocationId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public enum ReferenceType {
|
||||
PRODUCTION_ORDER,
|
||||
SALE_ORDER
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class Reservation {
|
||||
|
||||
private final ReservationId id;
|
||||
private final ReferenceType referenceType;
|
||||
private final String referenceId;
|
||||
private final Quantity quantity;
|
||||
private final ReservationPriority priority;
|
||||
private final Instant reservedAt;
|
||||
private final List<StockBatchAllocation> allocations;
|
||||
|
||||
public Reservation(ReservationId id, ReferenceType referenceType, String referenceId,
|
||||
Quantity quantity, ReservationPriority priority, Instant reservedAt,
|
||||
List<StockBatchAllocation> allocations) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.referenceType = Objects.requireNonNull(referenceType);
|
||||
this.referenceId = Objects.requireNonNull(referenceId);
|
||||
this.quantity = Objects.requireNonNull(quantity);
|
||||
this.priority = Objects.requireNonNull(priority);
|
||||
this.reservedAt = Objects.requireNonNull(reservedAt);
|
||||
this.allocations = List.copyOf(allocations);
|
||||
}
|
||||
|
||||
public ReservationId id() { return id; }
|
||||
public ReferenceType referenceType() { return referenceType; }
|
||||
public String referenceId() { return referenceId; }
|
||||
public Quantity quantity() { return quantity; }
|
||||
public ReservationPriority priority() { return priority; }
|
||||
public Instant reservedAt() { return reservedAt; }
|
||||
public List<StockBatchAllocation> allocations() { return allocations; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof Reservation other)) return false;
|
||||
return Objects.equals(id, other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public record ReservationDraft(
|
||||
String referenceType,
|
||||
String referenceId,
|
||||
String quantityAmount,
|
||||
String quantityUnit,
|
||||
String priority
|
||||
) {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record ReservationId(String value) {
|
||||
|
||||
public ReservationId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("ReservationId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static ReservationId generate() {
|
||||
return new ReservationId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static ReservationId of(String value) {
|
||||
return new ReservationId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
public enum ReservationPriority {
|
||||
URGENT,
|
||||
NORMAL,
|
||||
LOW
|
||||
}
|
||||
|
|
@ -6,10 +6,14 @@ import de.effigenix.shared.common.Result;
|
|||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
|
@ -26,8 +30,10 @@ import java.util.stream.Collectors;
|
|||
* - unblockBatch: BLOCKED → AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED → error
|
||||
* - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today → EXPIRED; BLOCKED untouched
|
||||
* - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired → EXPIRING_SOON; requires minimumShelfLife
|
||||
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities
|
||||
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations
|
||||
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
|
||||
* - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
|
||||
* - reservations track allocated quantities per batch; no over-reservation possible
|
||||
*/
|
||||
public class Stock {
|
||||
|
||||
|
|
@ -39,6 +45,7 @@ public class Stock {
|
|||
private MinimumLevel minimumLevel;
|
||||
private MinimumShelfLife minimumShelfLife;
|
||||
private final List<StockBatch> batches;
|
||||
private final List<Reservation> reservations;
|
||||
|
||||
private Stock(
|
||||
StockId id,
|
||||
|
|
@ -46,7 +53,8 @@ public class Stock {
|
|||
StorageLocationId storageLocationId,
|
||||
MinimumLevel minimumLevel,
|
||||
MinimumShelfLife minimumShelfLife,
|
||||
List<StockBatch> batches
|
||||
List<StockBatch> batches,
|
||||
List<Reservation> reservations
|
||||
) {
|
||||
this.id = id;
|
||||
this.articleId = articleId;
|
||||
|
|
@ -54,6 +62,7 @@ public class Stock {
|
|||
this.minimumLevel = minimumLevel;
|
||||
this.minimumShelfLife = minimumShelfLife;
|
||||
this.batches = new ArrayList<>(batches);
|
||||
this.reservations = new ArrayList<>(reservations);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,7 +111,7 @@ public class Stock {
|
|||
}
|
||||
|
||||
return Result.success(new Stock(
|
||||
StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife, List.of()
|
||||
StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife, List.of(), List.of()
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -115,9 +124,10 @@ public class Stock {
|
|||
StorageLocationId storageLocationId,
|
||||
MinimumLevel minimumLevel,
|
||||
MinimumShelfLife minimumShelfLife,
|
||||
List<StockBatch> batches
|
||||
List<StockBatch> batches,
|
||||
List<Reservation> reservations
|
||||
) {
|
||||
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches);
|
||||
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches, reservations);
|
||||
}
|
||||
|
||||
// ==================== Update ====================
|
||||
|
|
@ -235,6 +245,114 @@ public class Stock {
|
|||
return Result.success(null);
|
||||
}
|
||||
|
||||
// ==================== Reservation (FEFO) ====================
|
||||
|
||||
public Result<StockError, Reservation> reserve(ReservationDraft draft) {
|
||||
// 1. ReferenceType validieren
|
||||
if (draft.referenceType() == null || draft.referenceType().isBlank()) {
|
||||
return Result.failure(new StockError.InvalidReferenceType("must not be blank"));
|
||||
}
|
||||
ReferenceType referenceType;
|
||||
try {
|
||||
referenceType = ReferenceType.valueOf(draft.referenceType());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new StockError.InvalidReferenceType(draft.referenceType()));
|
||||
}
|
||||
|
||||
// 2. ReferenceId validieren
|
||||
if (draft.referenceId() == null || draft.referenceId().isBlank()) {
|
||||
return Result.failure(new StockError.InvalidReferenceId("must not be blank"));
|
||||
}
|
||||
|
||||
// 3. Quantity validieren
|
||||
Quantity requestedQuantity;
|
||||
try {
|
||||
BigDecimal amount = new BigDecimal(draft.quantityAmount());
|
||||
UnitOfMeasure uom;
|
||||
try {
|
||||
uom = UnitOfMeasure.valueOf(draft.quantityUnit());
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
return Result.failure(new StockError.InvalidQuantity("Invalid unit: " + draft.quantityUnit()));
|
||||
}
|
||||
switch (Quantity.of(amount, uom)) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new StockError.InvalidQuantity(err.message()));
|
||||
}
|
||||
case Result.Success(var val) -> requestedQuantity = val;
|
||||
}
|
||||
} catch (NumberFormatException | NullPointerException e) {
|
||||
return Result.failure(new StockError.InvalidQuantity("Invalid quantity amount: " + draft.quantityAmount()));
|
||||
}
|
||||
|
||||
// 4. Priority validieren
|
||||
if (draft.priority() == null || draft.priority().isBlank()) {
|
||||
return Result.failure(new StockError.InvalidReservationPriority("must not be blank"));
|
||||
}
|
||||
ReservationPriority priority;
|
||||
try {
|
||||
priority = ReservationPriority.valueOf(draft.priority());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new StockError.InvalidReservationPriority(draft.priority()));
|
||||
}
|
||||
|
||||
// 5. Verfügbare Chargen ermitteln (AVAILABLE + EXPIRING_SOON), sortiert nach expiryDate ASC (FEFO)
|
||||
List<StockBatch> availableBatches = batches.stream()
|
||||
.filter(StockBatch::isRemovable)
|
||||
.sorted(Comparator.comparing(StockBatch::expiryDate, Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
|
||||
// 6. Bereits allokierte Mengen pro Batch berechnen
|
||||
Map<StockBatchId, BigDecimal> allocatedPerBatch = new HashMap<>();
|
||||
for (Reservation r : reservations) {
|
||||
for (StockBatchAllocation alloc : r.allocations()) {
|
||||
allocatedPerBatch.merge(alloc.stockBatchId(), alloc.allocatedQuantity().amount(), BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Verfügbare Menge prüfen
|
||||
BigDecimal totalAvailable = BigDecimal.ZERO;
|
||||
for (StockBatch batch : availableBatches) {
|
||||
BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
|
||||
BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
|
||||
if (free.compareTo(BigDecimal.ZERO) > 0) {
|
||||
totalAvailable = totalAvailable.add(free);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAvailable.compareTo(requestedQuantity.amount()) < 0) {
|
||||
return Result.failure(new StockError.InsufficientStock(
|
||||
totalAvailable.toPlainString(), requestedQuantity.amount().toPlainString()));
|
||||
}
|
||||
|
||||
// 8. FEFO-Allokation
|
||||
List<StockBatchAllocation> allocations = new ArrayList<>();
|
||||
BigDecimal remaining = requestedQuantity.amount();
|
||||
for (StockBatch batch : availableBatches) {
|
||||
if (remaining.compareTo(BigDecimal.ZERO) <= 0) break;
|
||||
|
||||
BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
|
||||
BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
|
||||
if (free.compareTo(BigDecimal.ZERO) <= 0) continue;
|
||||
|
||||
BigDecimal toAllocate = remaining.min(free);
|
||||
allocations.add(new StockBatchAllocation(
|
||||
AllocationId.generate(),
|
||||
batch.id(),
|
||||
Quantity.reconstitute(toAllocate, requestedQuantity.uom())
|
||||
));
|
||||
remaining = remaining.subtract(toAllocate);
|
||||
}
|
||||
|
||||
// 9. Reservation erstellen
|
||||
var reservation = new Reservation(
|
||||
ReservationId.generate(), referenceType, draft.referenceId(),
|
||||
requestedQuantity, priority, Instant.now(), allocations
|
||||
);
|
||||
this.reservations.add(reservation);
|
||||
|
||||
return Result.success(reservation);
|
||||
}
|
||||
|
||||
// ==================== Expiry Management ====================
|
||||
|
||||
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
|
||||
|
|
@ -273,10 +391,18 @@ public class Stock {
|
|||
// ==================== Queries ====================
|
||||
|
||||
public BigDecimal availableQuantity() {
|
||||
return batches.stream()
|
||||
BigDecimal gross = batches.stream()
|
||||
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
|
||||
.map(b -> b.quantity().amount())
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal totalAllocated = reservations.stream()
|
||||
.flatMap(r -> r.allocations().stream())
|
||||
.map(a -> a.allocatedQuantity().amount())
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal net = gross.subtract(totalAllocated);
|
||||
return net.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : net;
|
||||
}
|
||||
|
||||
public boolean isBelowMinimumLevel() {
|
||||
|
|
@ -305,6 +431,7 @@ public class Stock {
|
|||
public MinimumLevel minimumLevel() { return minimumLevel; }
|
||||
public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; }
|
||||
public List<StockBatch> batches() { return Collections.unmodifiableList(batches); }
|
||||
public List<Reservation> reservations() { return Collections.unmodifiableList(reservations); }
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package de.effigenix.domain.inventory;
|
||||
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class StockBatchAllocation {
|
||||
|
||||
private final AllocationId id;
|
||||
private final StockBatchId stockBatchId;
|
||||
private final Quantity allocatedQuantity;
|
||||
|
||||
public StockBatchAllocation(AllocationId id, StockBatchId stockBatchId, Quantity allocatedQuantity) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.stockBatchId = Objects.requireNonNull(stockBatchId);
|
||||
this.allocatedQuantity = Objects.requireNonNull(allocatedQuantity);
|
||||
}
|
||||
|
||||
public AllocationId id() { return id; }
|
||||
public StockBatchId stockBatchId() { return stockBatchId; }
|
||||
public Quantity allocatedQuantity() { return allocatedQuantity; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof StockBatchAllocation other)) return false;
|
||||
return Objects.equals(id, other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,31 @@ public sealed interface StockError {
|
|||
@Override public String message() { return "Batch " + id + " is not blocked"; }
|
||||
}
|
||||
|
||||
record InsufficientStock(String available, String requested) implements StockError {
|
||||
@Override public String code() { return "INSUFFICIENT_STOCK"; }
|
||||
@Override public String message() { return "Insufficient stock: available " + available + ", requested " + requested; }
|
||||
}
|
||||
|
||||
record InvalidReferenceType(String value) implements StockError {
|
||||
@Override public String code() { return "INVALID_REFERENCE_TYPE"; }
|
||||
@Override public String message() { return "Invalid reference type: " + value; }
|
||||
}
|
||||
|
||||
record InvalidReferenceId(String reason) implements StockError {
|
||||
@Override public String code() { return "INVALID_REFERENCE_ID"; }
|
||||
@Override public String message() { return "Invalid reference ID: " + reason; }
|
||||
}
|
||||
|
||||
record InvalidReservationPriority(String value) implements StockError {
|
||||
@Override public String code() { return "INVALID_RESERVATION_PRIORITY"; }
|
||||
@Override public String message() { return "Invalid reservation priority: " + value; }
|
||||
}
|
||||
|
||||
record ReservationNotFound(String id) implements StockError {
|
||||
@Override public String code() { return "RESERVATION_NOT_FOUND"; }
|
||||
@Override public String message() { return "Reservation not found: " + id; }
|
||||
}
|
||||
|
||||
record InvalidFilterCombination(String message) implements StockError {
|
||||
@Override public String code() { return "INVALID_FILTER_COMBINATION"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import de.effigenix.application.inventory.UpdateStock;
|
|||
import de.effigenix.application.inventory.ListStocks;
|
||||
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
||||
import de.effigenix.application.inventory.RemoveStockBatch;
|
||||
import de.effigenix.application.inventory.ReserveStock;
|
||||
import de.effigenix.application.inventory.UnblockStockBatch;
|
||||
import de.effigenix.application.inventory.CreateStorageLocation;
|
||||
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
||||
|
|
@ -100,6 +101,11 @@ public class InventoryUseCaseConfiguration {
|
|||
return new UnblockStockBatch(stockRepository, auditLogger);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ReserveStock reserveStock(StockRepository stockRepository) {
|
||||
return new ReserveStock(stockRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
|
||||
return new CheckStockExpiry(stockRepository);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package de.effigenix.infrastructure.inventory.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "reservations")
|
||||
public class ReservationEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "stock_id", nullable = false)
|
||||
private StockEntity stock;
|
||||
|
||||
@Column(name = "reference_type", nullable = false, length = 30)
|
||||
private String referenceType;
|
||||
|
||||
@Column(name = "reference_id", nullable = false, length = 100)
|
||||
private String referenceId;
|
||||
|
||||
@Column(name = "quantity_amount", nullable = false, precision = 19, scale = 6)
|
||||
private BigDecimal quantityAmount;
|
||||
|
||||
@Column(name = "quantity_unit", nullable = false, length = 20)
|
||||
private String quantityUnit;
|
||||
|
||||
@Column(name = "priority", nullable = false, length = 20)
|
||||
private String priority;
|
||||
|
||||
@Column(name = "reserved_at", nullable = false)
|
||||
private Instant reservedAt;
|
||||
|
||||
@OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
private List<StockBatchAllocationEntity> allocations = new ArrayList<>();
|
||||
|
||||
protected ReservationEntity() {}
|
||||
|
||||
public ReservationEntity(String id, StockEntity stock, String referenceType, String referenceId,
|
||||
BigDecimal quantityAmount, String quantityUnit, String priority,
|
||||
Instant reservedAt) {
|
||||
this.id = id;
|
||||
this.stock = stock;
|
||||
this.referenceType = referenceType;
|
||||
this.referenceId = referenceId;
|
||||
this.quantityAmount = quantityAmount;
|
||||
this.quantityUnit = quantityUnit;
|
||||
this.priority = priority;
|
||||
this.reservedAt = reservedAt;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public StockEntity getStock() { return stock; }
|
||||
public void setStock(StockEntity stock) { this.stock = stock; }
|
||||
public String getReferenceType() { return referenceType; }
|
||||
public void setReferenceType(String referenceType) { this.referenceType = referenceType; }
|
||||
public String getReferenceId() { return referenceId; }
|
||||
public void setReferenceId(String referenceId) { this.referenceId = referenceId; }
|
||||
public BigDecimal getQuantityAmount() { return quantityAmount; }
|
||||
public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; }
|
||||
public String getQuantityUnit() { return quantityUnit; }
|
||||
public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; }
|
||||
public String getPriority() { return priority; }
|
||||
public void setPriority(String priority) { this.priority = priority; }
|
||||
public Instant getReservedAt() { return reservedAt; }
|
||||
public void setReservedAt(Instant reservedAt) { this.reservedAt = reservedAt; }
|
||||
public List<StockBatchAllocationEntity> getAllocations() { return allocations; }
|
||||
public void setAllocations(List<StockBatchAllocationEntity> allocations) { this.allocations = allocations; }
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package de.effigenix.infrastructure.inventory.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Entity
|
||||
@Table(name = "stock_batch_allocations")
|
||||
public class StockBatchAllocationEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "reservation_id", nullable = false)
|
||||
private ReservationEntity reservation;
|
||||
|
||||
@Column(name = "stock_batch_id", nullable = false, length = 36)
|
||||
private String stockBatchId;
|
||||
|
||||
@Column(name = "allocated_quantity_amount", nullable = false, precision = 19, scale = 6)
|
||||
private BigDecimal allocatedQuantityAmount;
|
||||
|
||||
@Column(name = "allocated_quantity_unit", nullable = false, length = 20)
|
||||
private String allocatedQuantityUnit;
|
||||
|
||||
protected StockBatchAllocationEntity() {}
|
||||
|
||||
public StockBatchAllocationEntity(String id, ReservationEntity reservation, String stockBatchId,
|
||||
BigDecimal allocatedQuantityAmount, String allocatedQuantityUnit) {
|
||||
this.id = id;
|
||||
this.reservation = reservation;
|
||||
this.stockBatchId = stockBatchId;
|
||||
this.allocatedQuantityAmount = allocatedQuantityAmount;
|
||||
this.allocatedQuantityUnit = allocatedQuantityUnit;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public ReservationEntity getReservation() { return reservation; }
|
||||
public void setReservation(ReservationEntity reservation) { this.reservation = reservation; }
|
||||
public String getStockBatchId() { return stockBatchId; }
|
||||
public void setStockBatchId(String stockBatchId) { this.stockBatchId = stockBatchId; }
|
||||
public BigDecimal getAllocatedQuantityAmount() { return allocatedQuantityAmount; }
|
||||
public void setAllocatedQuantityAmount(BigDecimal allocatedQuantityAmount) { this.allocatedQuantityAmount = allocatedQuantityAmount; }
|
||||
public String getAllocatedQuantityUnit() { return allocatedQuantityUnit; }
|
||||
public void setAllocatedQuantityUnit(String allocatedQuantityUnit) { this.allocatedQuantityUnit = allocatedQuantityUnit; }
|
||||
}
|
||||
|
|
@ -32,6 +32,9 @@ public class StockEntity {
|
|||
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private List<StockBatchEntity> batches = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
private List<ReservationEntity> reservations = new ArrayList<>();
|
||||
|
||||
public StockEntity() {}
|
||||
|
||||
// ==================== Getters & Setters ====================
|
||||
|
|
@ -56,4 +59,7 @@ public class StockEntity {
|
|||
|
||||
public List<StockBatchEntity> getBatches() { return batches; }
|
||||
public void setBatches(List<StockBatchEntity> batches) { this.batches = batches; }
|
||||
|
||||
public List<ReservationEntity> getReservations() { return reservations; }
|
||||
public void setReservations(List<ReservationEntity> reservations) { this.reservations = reservations; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import de.effigenix.domain.inventory.*;
|
|||
import de.effigenix.domain.masterdata.ArticleId;
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
import de.effigenix.infrastructure.inventory.persistence.entity.ReservationEntity;
|
||||
import de.effigenix.infrastructure.inventory.persistence.entity.StockBatchAllocationEntity;
|
||||
import de.effigenix.infrastructure.inventory.persistence.entity.StockBatchEntity;
|
||||
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
|
@ -34,6 +36,11 @@ public class StockMapper {
|
|||
.collect(Collectors.toList());
|
||||
entity.setBatches(batchEntities);
|
||||
|
||||
List<ReservationEntity> reservationEntities = stock.reservations().stream()
|
||||
.map(r -> toReservationEntity(r, entity))
|
||||
.collect(Collectors.toList());
|
||||
entity.setReservations(reservationEntities);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -56,13 +63,18 @@ public class StockMapper {
|
|||
.map(this::toDomainBatch)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Reservation> reservations = entity.getReservations().stream()
|
||||
.map(this::toDomainReservation)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Stock.reconstitute(
|
||||
StockId.of(entity.getId()),
|
||||
ArticleId.of(entity.getArticleId()),
|
||||
StorageLocationId.of(entity.getStorageLocationId()),
|
||||
minimumLevel,
|
||||
minimumShelfLife,
|
||||
batches
|
||||
batches,
|
||||
reservations
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -93,4 +105,64 @@ public class StockMapper {
|
|||
entity.getReceivedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private ReservationEntity toReservationEntity(Reservation reservation, StockEntity stockEntity) {
|
||||
var entity = new ReservationEntity(
|
||||
reservation.id().value(),
|
||||
stockEntity,
|
||||
reservation.referenceType().name(),
|
||||
reservation.referenceId(),
|
||||
reservation.quantity().amount(),
|
||||
reservation.quantity().uom().name(),
|
||||
reservation.priority().name(),
|
||||
reservation.reservedAt()
|
||||
);
|
||||
|
||||
List<StockBatchAllocationEntity> allocationEntities = reservation.allocations().stream()
|
||||
.map(a -> toAllocationEntity(a, entity))
|
||||
.collect(Collectors.toList());
|
||||
entity.setAllocations(allocationEntities);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private StockBatchAllocationEntity toAllocationEntity(StockBatchAllocation allocation, ReservationEntity reservationEntity) {
|
||||
return new StockBatchAllocationEntity(
|
||||
allocation.id().value(),
|
||||
reservationEntity,
|
||||
allocation.stockBatchId().value(),
|
||||
allocation.allocatedQuantity().amount(),
|
||||
allocation.allocatedQuantity().uom().name()
|
||||
);
|
||||
}
|
||||
|
||||
private Reservation toDomainReservation(ReservationEntity entity) {
|
||||
List<StockBatchAllocation> allocations = entity.getAllocations().stream()
|
||||
.map(this::toDomainAllocation)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new Reservation(
|
||||
ReservationId.of(entity.getId()),
|
||||
ReferenceType.valueOf(entity.getReferenceType()),
|
||||
entity.getReferenceId(),
|
||||
Quantity.reconstitute(
|
||||
entity.getQuantityAmount(),
|
||||
UnitOfMeasure.valueOf(entity.getQuantityUnit())
|
||||
),
|
||||
ReservationPriority.valueOf(entity.getPriority()),
|
||||
entity.getReservedAt(),
|
||||
allocations
|
||||
);
|
||||
}
|
||||
|
||||
private StockBatchAllocation toDomainAllocation(StockBatchAllocationEntity entity) {
|
||||
return new StockBatchAllocation(
|
||||
AllocationId.of(entity.getId()),
|
||||
StockBatchId.of(entity.getStockBatchId()),
|
||||
Quantity.reconstitute(
|
||||
entity.getAllocatedQuantityAmount(),
|
||||
UnitOfMeasure.valueOf(entity.getAllocatedQuantityUnit())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import de.effigenix.application.inventory.GetStock;
|
|||
import de.effigenix.application.inventory.ListStocks;
|
||||
import de.effigenix.application.inventory.ListStocksBelowMinimum;
|
||||
import de.effigenix.application.inventory.RemoveStockBatch;
|
||||
import de.effigenix.application.inventory.ReserveStock;
|
||||
import de.effigenix.application.inventory.UnblockStockBatch;
|
||||
import de.effigenix.application.inventory.UpdateStock;
|
||||
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
||||
import de.effigenix.application.inventory.command.BlockStockBatchCommand;
|
||||
import de.effigenix.application.inventory.command.CreateStockCommand;
|
||||
import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
|
||||
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
||||
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
|
||||
import de.effigenix.application.inventory.command.UpdateStockCommand;
|
||||
import de.effigenix.domain.inventory.StockError;
|
||||
|
|
@ -22,6 +24,8 @@ import de.effigenix.infrastructure.inventory.web.dto.BlockStockBatchRequest;
|
|||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.ReservationResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.ReserveStockRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.UpdateStockRequest;
|
||||
|
|
@ -55,11 +59,13 @@ public class StockController {
|
|||
private final RemoveStockBatch removeStockBatch;
|
||||
private final BlockStockBatch blockStockBatch;
|
||||
private final UnblockStockBatch unblockStockBatch;
|
||||
private final ReserveStock reserveStock;
|
||||
|
||||
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
|
||||
ListStocksBelowMinimum listStocksBelowMinimum,
|
||||
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
|
||||
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
|
||||
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
|
||||
ReserveStock reserveStock) {
|
||||
this.createStock = createStock;
|
||||
this.updateStock = updateStock;
|
||||
this.getStock = getStock;
|
||||
|
|
@ -69,6 +75,7 @@ public class StockController {
|
|||
this.removeStockBatch = removeStockBatch;
|
||||
this.blockStockBatch = blockStockBatch;
|
||||
this.unblockStockBatch = unblockStockBatch;
|
||||
this.reserveStock = reserveStock;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
|
@ -255,6 +262,30 @@ public class StockController {
|
|||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{stockId}/reservations")
|
||||
@PreAuthorize("hasAuthority('STOCK_WRITE')")
|
||||
public ResponseEntity<ReservationResponse> reserveStock(
|
||||
@PathVariable String stockId,
|
||||
@Valid @RequestBody ReserveStockRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
logger.info("Reserving stock {} by actor: {}", stockId, authentication.getName());
|
||||
|
||||
var cmd = new ReserveStockCommand(
|
||||
stockId, request.referenceType(), request.referenceId(),
|
||||
request.quantityAmount(), request.quantityUnit(), request.priority()
|
||||
);
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new StockDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Reservation created: {} for stock {}", result.unsafeGetValue().id().value(), stockId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ReservationResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
public static class StockDomainErrorException extends RuntimeException {
|
||||
private final StockError error;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import de.effigenix.domain.inventory.Reservation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record ReservationResponse(
|
||||
String id,
|
||||
String referenceType,
|
||||
String referenceId,
|
||||
BigDecimal quantityAmount,
|
||||
String quantityUnit,
|
||||
String priority,
|
||||
Instant reservedAt,
|
||||
List<StockBatchAllocationResponse> allocations
|
||||
) {
|
||||
|
||||
public static ReservationResponse from(Reservation reservation) {
|
||||
return new ReservationResponse(
|
||||
reservation.id().value(),
|
||||
reservation.referenceType().name(),
|
||||
reservation.referenceId(),
|
||||
reservation.quantity().amount(),
|
||||
reservation.quantity().uom().name(),
|
||||
reservation.priority().name(),
|
||||
reservation.reservedAt(),
|
||||
reservation.allocations().stream()
|
||||
.map(StockBatchAllocationResponse::from)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ReserveStockRequest(
|
||||
@NotBlank String referenceType,
|
||||
@NotBlank String referenceId,
|
||||
@NotBlank String quantityAmount,
|
||||
@NotBlank String quantityUnit,
|
||||
@NotBlank String priority
|
||||
) {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import de.effigenix.domain.inventory.StockBatchAllocation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record StockBatchAllocationResponse(
|
||||
String stockBatchId,
|
||||
BigDecimal allocatedQuantityAmount,
|
||||
String allocatedQuantityUnit
|
||||
) {
|
||||
|
||||
public static StockBatchAllocationResponse from(StockBatchAllocation allocation) {
|
||||
return new StockBatchAllocationResponse(
|
||||
allocation.stockBatchId().value(),
|
||||
allocation.allocatedQuantity().amount(),
|
||||
allocation.allocatedQuantity().uom().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity"})
|
||||
@Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity", "reservations"})
|
||||
public record StockResponse(
|
||||
String id,
|
||||
String articleId,
|
||||
|
|
@ -16,7 +16,8 @@ public record StockResponse(
|
|||
List<StockBatchResponse> batches,
|
||||
BigDecimal totalQuantity,
|
||||
@Schema(nullable = true) String quantityUnit,
|
||||
BigDecimal availableQuantity
|
||||
BigDecimal availableQuantity,
|
||||
List<ReservationResponse> reservations
|
||||
) {
|
||||
|
||||
public static StockResponse from(Stock stock) {
|
||||
|
|
@ -47,6 +48,10 @@ public record StockResponse(
|
|||
? null
|
||||
: stock.batches().getFirst().quantity().uom().name();
|
||||
|
||||
List<ReservationResponse> reservationResponses = stock.reservations().stream()
|
||||
.map(ReservationResponse::from)
|
||||
.toList();
|
||||
|
||||
return new StockResponse(
|
||||
stock.id().value(),
|
||||
stock.articleId().value(),
|
||||
|
|
@ -56,7 +61,8 @@ public record StockResponse(
|
|||
batchResponses,
|
||||
totalQuantity,
|
||||
quantityUnit,
|
||||
availableQuantity
|
||||
availableQuantity,
|
||||
reservationResponses
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ public final class InventoryErrorHttpStatusMapper {
|
|||
return switch (error) {
|
||||
case StockError.StockNotFound e -> 404;
|
||||
case StockError.BatchNotFound e -> 404;
|
||||
case StockError.ReservationNotFound e -> 404;
|
||||
case StockError.DuplicateStock e -> 409;
|
||||
case StockError.DuplicateBatchReference e -> 409;
|
||||
case StockError.NegativeStockNotAllowed e -> 409;
|
||||
case StockError.BatchNotAvailable e -> 409;
|
||||
case StockError.BatchAlreadyBlocked e -> 409;
|
||||
case StockError.BatchNotBlocked e -> 409;
|
||||
case StockError.InsufficientStock e -> 409;
|
||||
case StockError.InvalidMinimumLevel e -> 400;
|
||||
case StockError.InvalidMinimumShelfLife e -> 400;
|
||||
case StockError.InvalidArticleId e -> 400;
|
||||
|
|
@ -40,6 +42,9 @@ public final class InventoryErrorHttpStatusMapper {
|
|||
case StockError.InvalidQuantity e -> 400;
|
||||
case StockError.InvalidExpiryDate e -> 400;
|
||||
case StockError.InvalidFilterCombination e -> 400;
|
||||
case StockError.InvalidReferenceType e -> 400;
|
||||
case StockError.InvalidReferenceId e -> 400;
|
||||
case StockError.InvalidReservationPriority e -> 400;
|
||||
case StockError.Unauthorized e -> 403;
|
||||
case StockError.RepositoryFailure e -> 500;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="024-create-reservations-table" author="effigenix">
|
||||
<createTable tableName="reservations">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="stock_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reference_type" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reference_id" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="quantity_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="quantity_unit" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="priority" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reserved_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="reservations" baseColumnNames="stock_id"
|
||||
referencedTableName="stocks" referencedColumnNames="id"
|
||||
constraintName="fk_reservations_stock_id"
|
||||
onDelete="CASCADE"/>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE reservations ADD CONSTRAINT chk_reservation_reference_type
|
||||
CHECK (reference_type IN ('PRODUCTION_ORDER', 'SALE_ORDER'));
|
||||
</sql>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE reservations ADD CONSTRAINT chk_reservation_priority
|
||||
CHECK (priority IN ('URGENT', 'NORMAL', 'LOW'));
|
||||
</sql>
|
||||
|
||||
<createIndex tableName="reservations" indexName="idx_reservations_stock_id">
|
||||
<column name="stock_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="reservations" indexName="idx_reservations_reference">
|
||||
<column name="reference_type"/>
|
||||
<column name="reference_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="024-create-stock-batch-allocations-table" author="effigenix">
|
||||
<createTable tableName="stock_batch_allocations">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="reservation_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="stock_batch_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="allocated_quantity_amount" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="allocated_quantity_unit" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="stock_batch_allocations" baseColumnNames="reservation_id"
|
||||
referencedTableName="reservations" referencedColumnNames="id"
|
||||
constraintName="fk_allocations_reservation_id"
|
||||
onDelete="CASCADE"/>
|
||||
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="stock_batch_allocations" baseColumnNames="stock_batch_id"
|
||||
referencedTableName="stock_batches" referencedColumnNames="id"
|
||||
constraintName="fk_allocations_stock_batch_id"
|
||||
onDelete="RESTRICT"/>
|
||||
|
||||
<createIndex tableName="stock_batch_allocations" indexName="idx_allocations_reservation_id">
|
||||
<column name="reservation_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="stock_batch_allocations" indexName="idx_allocations_stock_batch_id">
|
||||
<column name="stock_batch_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -30,5 +30,6 @@
|
|||
<include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/>
|
||||
<include file="db/changelog/changes/024-create-production-orders-table.xml"/>
|
||||
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
|
||||
<include file="db/changelog/changes/026-create-reservations-schema.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class AddStockBatchTest {
|
|||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
validCommand = new AddStockBatchCommand(
|
||||
|
|
@ -123,7 +123,7 @@ class AddStockBatchTest {
|
|||
LocalDate.of(2026, 12, 31),
|
||||
StockBatchStatus.AVAILABLE,
|
||||
Instant.now()
|
||||
))
|
||||
)), List.of()
|
||||
);
|
||||
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class BlockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue");
|
||||
|
|
@ -153,7 +153,7 @@ class BlockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(blockedBatch))
|
||||
new ArrayList<>(List.of(blockedBatch)), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stock)));
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class CheckStockExpiryTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(expiredBatch, expiringSoonBatch))
|
||||
new ArrayList<>(List.of(expiredBatch, expiringSoonBatch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock1 = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch1))
|
||||
null, null, new ArrayList<>(List.of(batch1)), List.of()
|
||||
);
|
||||
|
||||
var batch2 = StockBatch.reconstitute(
|
||||
|
|
@ -113,7 +113,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock2 = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-2"), StorageLocationId.of("location-2"),
|
||||
null, null, new ArrayList<>(List.of(batch2))
|
||||
null, null, new ArrayList<>(List.of(batch2)), List.of()
|
||||
);
|
||||
|
||||
stockRepository.addStock(stock1);
|
||||
|
|
@ -146,7 +146,7 @@ class CheckStockExpiryTest {
|
|||
// No minimumShelfLife → expiringSoon should not be marked
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(expiredBatch, soonBatch))
|
||||
null, null, new ArrayList<>(List.of(expiredBatch, soonBatch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch))
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
stockRepository.failOnSave = true;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class DeactivateStorageLocationTest {
|
|||
var locationId = StorageLocationId.of("loc-1");
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.of("stock-1"), ArticleId.of("article-1"),
|
||||
locationId, null, null, List.of()
|
||||
locationId, null, null, List.of(), List.of()
|
||||
);
|
||||
when(storageLocationRepository.findById(locationId))
|
||||
.thenReturn(Result.success(Optional.of(activeLocation("loc-1"))));
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class GetStockTest {
|
|||
StockId.of("stock-1"),
|
||||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class ListStocksBelowMinimumTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal(minimumAmount), UnitOfMeasure.KILOGRAM));
|
||||
return Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-" + availableAmount), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ class ListStocksTest {
|
|||
StockId.of("stock-1"),
|
||||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
stock2 = Stock.reconstitute(
|
||||
StockId.of("stock-2"),
|
||||
ArticleId.of("article-2"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class RemoveStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new RemoveStockBatchCommand("stock-1", "batch-1", "5", "KILOGRAM");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ReserveStock Use Case")
|
||||
class ReserveStockTest {
|
||||
|
||||
@Mock private StockRepository stockRepository;
|
||||
|
||||
private ReserveStock reserveStock;
|
||||
private ReserveStockCommand validCommand;
|
||||
private Stock existingStock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
reserveStock = new ReserveStock(stockRepository);
|
||||
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("50"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31),
|
||||
StockBatchStatus.AVAILABLE,
|
||||
Instant.now()
|
||||
);
|
||||
existingStock = Stock.reconstitute(
|
||||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new ReserveStockCommand(
|
||||
"stock-1", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve stock successfully")
|
||||
void shouldReserveStockSuccessfully() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var reservation = result.unsafeGetValue();
|
||||
assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
|
||||
assertThat(reservation.referenceId()).isEqualTo("PO-001");
|
||||
assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("10"));
|
||||
assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
|
||||
assertThat(reservation.allocations()).isNotEmpty();
|
||||
verify(stockRepository).save(existingStock);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stock does not exist")
|
||||
void shouldFailWhenStockNotFound() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stockId is null")
|
||||
void shouldFailWhenStockIdNull() {
|
||||
var cmd = new ReserveStockCommand(null, "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stockId is blank")
|
||||
void shouldFailWhenStockIdBlank() {
|
||||
var cmd = new ReserveStockCommand("", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when findById fails")
|
||||
void shouldFailWhenFindByIdFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when save fails")
|
||||
void shouldFailWhenSaveFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InsufficientStock from domain")
|
||||
void shouldPropagateInsufficientStock() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "999", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidReferenceType from domain")
|
||||
void shouldPropagateInvalidReferenceType() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "INVALID_TYPE", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidReservationPriority from domain")
|
||||
void shouldPropagateInvalidPriority() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "SUPER_HIGH");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidQuantity from domain")
|
||||
void shouldPropagateInvalidQuantity() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ class UnblockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new UnblockStockBatchCommand("stock-1", "batch-1");
|
||||
|
|
@ -153,7 +153,7 @@ class UnblockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(availableBatch))
|
||||
new ArrayList<>(List.of(availableBatch)), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stock)));
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class UpdateStockTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
List.of()
|
||||
List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ class UpdateStockTest {
|
|||
StorageLocationId.of("location-1"),
|
||||
MinimumLevel.of("25", "KILOGRAM").unsafeGetValue(),
|
||||
MinimumShelfLife.of(7).unsafeGetValue(),
|
||||
List.of()
|
||||
List.of(), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stockWithParams)));
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(quantity);
|
||||
var minimumShelfLife = new MinimumShelfLife(30);
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of());
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of(), List.of());
|
||||
|
||||
assertThat(stock.id()).isEqualTo(id);
|
||||
assertThat(stock.articleId()).isEqualTo(articleId);
|
||||
|
|
@ -503,7 +503,7 @@ class StockTest {
|
|||
var articleId = ArticleId.of("article-1");
|
||||
var locationId = StorageLocationId.of("location-1");
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of());
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of(), List.of());
|
||||
|
||||
assertThat(stock.minimumLevel()).isNull();
|
||||
assertThat(stock.minimumShelfLife()).isNull();
|
||||
|
|
@ -520,8 +520,8 @@ class StockTest {
|
|||
@DisplayName("should be equal if same ID")
|
||||
void shouldBeEqualBySameId() {
|
||||
var id = StockId.generate();
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of());
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of());
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of(), List.of());
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of(), List.of());
|
||||
|
||||
assertThat(stock1).isEqualTo(stock2);
|
||||
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
|
||||
|
|
@ -751,7 +751,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
|
|
@ -777,7 +777,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
|
|
@ -918,7 +918,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable))
|
||||
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiredBatches(today);
|
||||
|
|
@ -978,7 +978,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1002,7 +1002,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1040,7 +1040,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1064,7 +1064,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1088,7 +1088,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1112,7 +1112,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1147,7 +1147,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold))
|
||||
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1173,7 +1173,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1219,7 +1219,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(available, expiringSoon, blocked, expired))
|
||||
new ArrayList<>(List.of(available, expiringSoon, blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
|
||||
|
|
@ -1250,7 +1250,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(blocked, expired))
|
||||
new ArrayList<>(List.of(blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
|
|
@ -1268,7 +1268,7 @@ class StockTest {
|
|||
void shouldReturnFalseWithoutMinimumLevel() {
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1286,7 +1286,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1304,7 +1304,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1322,7 +1322,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1334,7 +1334,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, List.of()
|
||||
minimumLevel, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1346,7 +1346,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, List.of()
|
||||
minimumLevel, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1370,7 +1370,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(blocked, expired))
|
||||
minimumLevel, null, new ArrayList<>(List.of(blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1394,13 +1394,400 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== reserve (FEFO) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("reserve()")
|
||||
class Reserve {
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve from single batch")
|
||||
void shouldReserveFromSingleBatch() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
var draft = new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
var result = stock.reserve(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var reservation = result.unsafeGetValue();
|
||||
assertThat(reservation.id()).isNotNull();
|
||||
assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
|
||||
assertThat(reservation.referenceId()).isEqualTo("PO-001");
|
||||
assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("5"));
|
||||
assertThat(reservation.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||
assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
|
||||
assertThat(reservation.reservedAt()).isNotNull();
|
||||
assertThat(reservation.allocations()).hasSize(1);
|
||||
assertThat(reservation.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
|
||||
assertThat(reservation.allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allocate FEFO – earliest expiry first")
|
||||
void shouldAllocateFefoEarliestFirst() {
|
||||
var earlyBatch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-EARLY", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var lateBatch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-LATE", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(lateBatch, earlyBatch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var allocs = result.unsafeGetValue().allocations();
|
||||
assertThat(allocs).hasSize(1);
|
||||
assertThat(allocs.getFirst().stockBatchId()).isEqualTo(earlyBatch.id());
|
||||
assertThat(allocs.getFirst().allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should split allocation across multiple batches (FEFO)")
|
||||
void shouldSplitAcrossMultipleBatches() {
|
||||
var batch1 = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var batch2 = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-002", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 9, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var allocs = result.unsafeGetValue().allocations();
|
||||
assertThat(allocs).hasSize(2);
|
||||
assertThat(allocs.get(0).stockBatchId()).isEqualTo(batch1.id());
|
||||
assertThat(allocs.get(0).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("6"));
|
||||
assertThat(allocs.get(1).stockBatchId()).isEqualTo(batch2.id());
|
||||
assertThat(allocs.get(1).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should include EXPIRING_SOON batches in allocation")
|
||||
void shouldIncludeExpiringSoonBatches() {
|
||||
var expiringSoon = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(expiringSoon)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
|
||||
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.URGENT);
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should skip BLOCKED and EXPIRED batches")
|
||||
void shouldSkipBlockedAndExpiredBatches() {
|
||||
var blocked = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-BLOCKED", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
|
||||
);
|
||||
var expired = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-EXPIRED", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
|
||||
);
|
||||
var available = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-AVAIL", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(blocked, expired, available)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().stockBatchId()).isEqualTo(available.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when not enough available")
|
||||
void shouldFailWhenInsufficientStock() {
|
||||
var stock = createStockWithBatchAndExpiry("5", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when all stock is already reserved")
|
||||
void shouldFailWhenAllStockAlreadyReserved() {
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var existingReservation = new Reservation(
|
||||
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
ReservationPriority.NORMAL, Instant.now(),
|
||||
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)))
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should consider existing reservations when allocating")
|
||||
void shouldConsiderExistingReservations() {
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var existingReservation = new Reservation(
|
||||
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
|
||||
Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
|
||||
ReservationPriority.NORMAL, Instant.now(),
|
||||
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM)))
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should update availableQuantity after reservation")
|
||||
void shouldUpdateAvailableQuantityAfterReservation() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));
|
||||
|
||||
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should add reservation to reservations list")
|
||||
void shouldAddReservationToList() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
assertThat(stock.reservations()).isEmpty();
|
||||
|
||||
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT"));
|
||||
|
||||
assertThat(stock.reservations()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when stock has no batches")
|
||||
void shouldFailWhenNoBatches() {
|
||||
var stock = createValidStock();
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "1", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve exact full amount of batch")
|
||||
void shouldReserveExactFullAmount() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("10"));
|
||||
}
|
||||
|
||||
// ==================== Validation Edge Cases ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceType when null")
|
||||
void shouldFailWhenReferenceTypeNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft(null, "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceType when invalid value")
|
||||
void shouldFailWhenReferenceTypeInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceId when referenceId is blank")
|
||||
void shouldFailWhenReferenceIdBlank() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceId when referenceId is null")
|
||||
void shouldFailWhenReferenceIdNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", null, "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when amount is not a number")
|
||||
void shouldFailWhenQuantityNotNumber() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "abc", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when amount is negative")
|
||||
void shouldFailWhenQuantityNegative() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when unit is invalid")
|
||||
void shouldFailWhenUnitInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "INVALID_UNIT", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReservationPriority when null")
|
||||
void shouldFailWhenPriorityNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", null));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReservationPriority when invalid value")
|
||||
void shouldFailWhenPriorityInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should accept LOW priority")
|
||||
void shouldAcceptLowPriority() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "LOW"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.LOW);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private Stock createValidStock() {
|
||||
|
|
@ -1426,7 +1813,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import de.effigenix.domain.usermanagement.RoleName;
|
|||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.ReserveStockRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -982,6 +983,220 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Bestand reservieren (FEFO) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /{stockId}/reservations – Bestand reservieren")
|
||||
class ReserveStockEndpoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Reservierung mit gültigen Daten → 201")
|
||||
void reserveStock_valid_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.referenceType").value("PRODUCTION_ORDER"))
|
||||
.andExpect(jsonPath("$.referenceId").value("PO-001"))
|
||||
.andExpect(jsonPath("$.quantityAmount").value(5))
|
||||
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
|
||||
.andExpect(jsonPath("$.priority").value("NORMAL"))
|
||||
.andExpect(jsonPath("$.reservedAt").isNotEmpty())
|
||||
.andExpect(jsonPath("$.allocations").isArray())
|
||||
.andExpect(jsonPath("$.allocations.length()").value(1))
|
||||
.andExpect(jsonPath("$.allocations[0].stockBatchId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.allocations[0].allocatedQuantityAmount").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SALE_ORDER mit URGENT-Priorität → 201")
|
||||
void reserveStock_saleOrderUrgent_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.referenceType").value("SALE_ORDER"))
|
||||
.andExpect(jsonPath("$.priority").value("URGENT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reservierung sichtbar bei GET Stock → 200")
|
||||
void reserveStock_visibleOnGet() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reservations").isArray())
|
||||
.andExpect(jsonPath("$.reservations.length()").value(1))
|
||||
.andExpect(jsonPath("$.reservations[0].referenceId").value("PO-001"))
|
||||
.andExpect(jsonPath("$.availableQuantity").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungenügender Bestand → 409")
|
||||
void reserveStock_insufficientStock_returns409() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId); // 10 KILOGRAM
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "999", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültiger ReferenceType → 400")
|
||||
void reserveStock_invalidReferenceType_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_REFERENCE_TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Priorität → 400")
|
||||
void reserveStock_invalidPriority_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_RESERVATION_PRIORITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Menge (negativ) → 400")
|
||||
void reserveStock_negativeQuantity_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Stock nicht gefunden → 404")
|
||||
void reserveStock_stockNotFound_returns404() throws Exception {
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne STOCK_WRITE → 403")
|
||||
void reserveStock_withViewerToken_returns403() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne Token → 401")
|
||||
void reserveStock_withoutToken_returns401() throws Exception {
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zweite Reservierung reduziert verfügbare Menge weiter → 201")
|
||||
void reserveStock_multipleReservations_reducesAvailability() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId); // 10 KILOGRAM
|
||||
|
||||
// Erste Reservierung: 6 kg
|
||||
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "6", "KILOGRAM", "NORMAL");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req1)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Zweite Reservierung: 4 kg (genau der Rest)
|
||||
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "4", "KILOGRAM", "URGENT");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req2)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Dritte Reservierung: sollte fehlschlagen (nichts mehr verfügbar)
|
||||
var req3 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "LOW");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req3)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Leerer referenceType (Blank) → 400 (Bean Validation)")
|
||||
void reserveStock_blankReferenceType_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"referenceType": "", "referenceId": "PO-001", "quantityAmount": "5", "quantityUnit": "KILOGRAM", "priority": "NORMAL"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hilfsmethoden ====================
|
||||
|
||||
private String createStorageLocation() throws Exception {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue