From 0b49bb2977ab157e9462df07ad13bca0dcda2ceb Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Mon, 23 Feb 2026 23:27:37 +0100 Subject: [PATCH] feat(inventory): Bestand reservieren mit FEFO-Allokation (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/docs/mvp/ddd/07-inventory-bc.md | 13 + .../application/inventory/ReserveStock.java | 58 +++ .../command/ReserveStockCommand.java | 10 + .../domain/inventory/AllocationId.java | 20 + .../domain/inventory/ReferenceType.java | 6 + .../domain/inventory/Reservation.java | 50 ++ .../domain/inventory/ReservationDraft.java | 9 + .../domain/inventory/ReservationId.java | 20 + .../domain/inventory/ReservationPriority.java | 7 + .../de/effigenix/domain/inventory/Stock.java | 139 +++++- .../inventory/StockBatchAllocation.java | 34 ++ .../domain/inventory/StockError.java | 25 + .../config/InventoryUseCaseConfiguration.java | 6 + .../persistence/entity/ReservationEntity.java | 76 +++ .../entity/StockBatchAllocationEntity.java | 49 ++ .../persistence/entity/StockEntity.java | 6 + .../persistence/mapper/StockMapper.java | 74 ++- .../web/controller/StockController.java | 33 +- .../web/dto/ReservationResponse.java | 34 ++ .../web/dto/ReserveStockRequest.java | 11 + .../web/dto/StockBatchAllocationResponse.java | 20 + .../inventory/web/dto/StockResponse.java | 12 +- .../InventoryErrorHttpStatusMapper.java | 5 + .../026-create-reservations-schema.xml | 102 ++++ .../db/changelog/db.changelog-master.xml | 1 + .../inventory/AddStockBatchTest.java | 4 +- .../inventory/BlockStockBatchTest.java | 4 +- .../inventory/CheckStockExpiryTest.java | 12 +- .../DeactivateStorageLocationTest.java | 2 +- .../application/inventory/GetStockTest.java | 2 +- .../inventory/ListStocksBelowMinimumTest.java | 2 +- .../application/inventory/ListStocksTest.java | 4 +- .../inventory/RemoveStockBatchTest.java | 2 +- .../inventory/ReserveStockTest.java | 199 ++++++++ .../inventory/UnblockStockBatchTest.java | 4 +- .../inventory/UpdateStockTest.java | 4 +- .../effigenix/domain/inventory/StockTest.java | 439 ++++++++++++++++-- .../web/StockControllerIntegrationTest.java | 215 +++++++++ 38 files changed, 1656 insertions(+), 57 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ReserveStock.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/ReserveStockCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/AllocationId.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/ReferenceType.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/Reservation.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/ReservationDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/ReservationId.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/ReservationPriority.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/StockBatchAllocation.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/ReservationEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchAllocationEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReservationResponse.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReserveStockRequest.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchAllocationResponse.java create mode 100644 backend/src/main/resources/db/changelog/changes/026-create-reservations-schema.xml create mode 100644 backend/src/test/java/de/effigenix/application/inventory/ReserveStockTest.java diff --git a/backend/docs/mvp/ddd/07-inventory-bc.md b/backend/docs/mvp/ddd/07-inventory-bc.md index 9674b78..0f30f79 100644 --- a/backend/docs/mvp/ddd/07-inventory-bc.md +++ b/backend/docs/mvp/ddd/07-inventory-bc.md @@ -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 { diff --git a/backend/src/main/java/de/effigenix/application/inventory/ReserveStock.java b/backend/src/main/java/de/effigenix/application/inventory/ReserveStock.java new file mode 100644 index 0000000..c46fac6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ReserveStock.java @@ -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 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); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/ReserveStockCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/ReserveStockCommand.java new file mode 100644 index 0000000..62ac41b --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/ReserveStockCommand.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/AllocationId.java b/backend/src/main/java/de/effigenix/domain/inventory/AllocationId.java new file mode 100644 index 0000000..d9e172c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/AllocationId.java @@ -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); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ReferenceType.java b/backend/src/main/java/de/effigenix/domain/inventory/ReferenceType.java new file mode 100644 index 0000000..c16d2aa --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ReferenceType.java @@ -0,0 +1,6 @@ +package de.effigenix.domain.inventory; + +public enum ReferenceType { + PRODUCTION_ORDER, + SALE_ORDER +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Reservation.java b/backend/src/main/java/de/effigenix/domain/inventory/Reservation.java new file mode 100644 index 0000000..792cfd6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/Reservation.java @@ -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 allocations; + + public Reservation(ReservationId id, ReferenceType referenceType, String referenceId, + Quantity quantity, ReservationPriority priority, Instant reservedAt, + List 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 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); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ReservationDraft.java b/backend/src/main/java/de/effigenix/domain/inventory/ReservationDraft.java new file mode 100644 index 0000000..8cf8b90 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ReservationDraft.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.inventory; + +public record ReservationDraft( + String referenceType, + String referenceId, + String quantityAmount, + String quantityUnit, + String priority +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ReservationId.java b/backend/src/main/java/de/effigenix/domain/inventory/ReservationId.java new file mode 100644 index 0000000..953c47b --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ReservationId.java @@ -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); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ReservationPriority.java b/backend/src/main/java/de/effigenix/domain/inventory/ReservationPriority.java new file mode 100644 index 0000000..6a30582 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ReservationPriority.java @@ -0,0 +1,7 @@ +package de.effigenix.domain.inventory; + +public enum ReservationPriority { + URGENT, + NORMAL, + LOW +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index f8f9bd8..4901a26 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -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 batches; + private final List reservations; private Stock( StockId id, @@ -46,7 +53,8 @@ public class Stock { StorageLocationId storageLocationId, MinimumLevel minimumLevel, MinimumShelfLife minimumShelfLife, - List batches + List batches, + List 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 batches + List batches, + List 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 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 availableBatches = batches.stream() + .filter(StockBatch::isRemovable) + .sorted(Comparator.comparing(StockBatch::expiryDate, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + // 6. Bereits allokierte Mengen pro Batch berechnen + Map 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 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> 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 batches() { return Collections.unmodifiableList(batches); } + public List reservations() { return Collections.unmodifiableList(reservations); } // ==================== Helpers ==================== diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatchAllocation.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchAllocation.java new file mode 100644 index 0000000..bd5eecd --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatchAllocation.java @@ -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); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java index d7e4328..853f3b7 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java @@ -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"; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index a58fa64..c519690 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -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); diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/ReservationEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/ReservationEntity.java new file mode 100644 index 0000000..d28fa34 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/ReservationEntity.java @@ -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 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 getAllocations() { return allocations; } + public void setAllocations(List allocations) { this.allocations = allocations; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchAllocationEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchAllocationEntity.java new file mode 100644 index 0000000..3f48023 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockBatchAllocationEntity.java @@ -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; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java index 1ee5942..655da46 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/entity/StockEntity.java @@ -32,6 +32,9 @@ public class StockEntity { @OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private List batches = new ArrayList<>(); + @OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List reservations = new ArrayList<>(); + public StockEntity() {} // ==================== Getters & Setters ==================== @@ -56,4 +59,7 @@ public class StockEntity { public List getBatches() { return batches; } public void setBatches(List batches) { this.batches = batches; } + + public List getReservations() { return reservations; } + public void setReservations(List reservations) { this.reservations = reservations; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java index 4f7c49e..64765d4 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/mapper/StockMapper.java @@ -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 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 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 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 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()) + ) + ); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index 918eda2..8c7d6fe 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -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 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; diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReservationResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReservationResponse.java new file mode 100644 index 0000000..e730ad1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReservationResponse.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReserveStockRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReserveStockRequest.java new file mode 100644 index 0000000..8a4a99e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/ReserveStockRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchAllocationResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchAllocationResponse.java new file mode 100644 index 0000000..60423aa --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockBatchAllocationResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java index 5bc8151..dc9733d 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java @@ -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 batches, BigDecimal totalQuantity, @Schema(nullable = true) String quantityUnit, - BigDecimal availableQuantity + BigDecimal availableQuantity, + List reservations ) { public static StockResponse from(Stock stock) { @@ -47,6 +48,10 @@ public record StockResponse( ? null : stock.batches().getFirst().quantity().uom().name(); + List 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 ); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java index 46ca089..b158999 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -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; }; diff --git a/backend/src/main/resources/db/changelog/changes/026-create-reservations-schema.xml b/backend/src/main/resources/db/changelog/changes/026-create-reservations-schema.xml new file mode 100644 index 0000000..8ded47c --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/026-create-reservations-schema.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE reservations ADD CONSTRAINT chk_reservation_reference_type + CHECK (reference_type IN ('PRODUCTION_ORDER', 'SALE_ORDER')); + + + + ALTER TABLE reservations ADD CONSTRAINT chk_reservation_priority + CHECK (priority IN ('URGENT', 'NORMAL', 'LOW')); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index fa33129..1290d24 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -30,5 +30,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java index d5bc719..02c148d 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/AddStockBatchTest.java @@ -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"))) diff --git a/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java index bcecfde..e690451 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java @@ -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))); diff --git a/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java index e786039..5588fa4 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java @@ -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; diff --git a/backend/src/test/java/de/effigenix/application/inventory/DeactivateStorageLocationTest.java b/backend/src/test/java/de/effigenix/application/inventory/DeactivateStorageLocationTest.java index d46e2fa..09a7f1e 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/DeactivateStorageLocationTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/DeactivateStorageLocationTest.java @@ -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")))); diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java index bb98d25..cb575fc 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/GetStockTest.java @@ -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() ); } diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java index 2340a60..c76c886 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java @@ -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() ); } diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java index 295b2ae..406672e 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStocksTest.java @@ -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() ); } diff --git a/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java index c87d3e1..22bd9a3 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java @@ -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"); diff --git a/backend/src/test/java/de/effigenix/application/inventory/ReserveStockTest.java b/backend/src/test/java/de/effigenix/application/inventory/ReserveStockTest.java new file mode 100644 index 0000000..07513e7 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ReserveStockTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java index dd63ead..dc22c38 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java @@ -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))); diff --git a/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java b/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java index 54554fa..c4eb407 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/UpdateStockTest.java @@ -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))); diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index 097464b..88e58e9 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -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() ); } } diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index 672f72b..e017318 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -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 {