1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 12:29:36 +01:00

feat(inventory): Bestand reservieren mit FEFO-Allokation (#12)

Implementiert Story 4.1: Reservierung von Beständen mit automatischer
FEFO-Allokation (First-Expired-First-Out) über verfügbare Chargen.

Domain: Reservation-Entity, StockBatchAllocation, ReservationDraft,
FEFO-Logik in Stock.reserve(), availableQuantity() berücksichtigt
bestehende Allokationen. Neue Error-Varianten für InsufficientStock,
InvalidReferenceType, InvalidReservationPriority, ReservationNotFound.

API: POST /api/inventory/stocks/{stockId}/reservations → 201 Created.
Liquibase: reservations + stock_batch_allocations Tabellen mit FK- und
CHECK-Constraints.

Tests: 43 neue Tests (22 Domain, 10 UseCase, 11 Integration) für
FEFO-Logik, Validierung, Mengenprüfung, Auth und Edge Cases.
This commit is contained in:
Sebastian Frick 2026-02-23 23:27:37 +01:00
parent b77b209f10
commit 0b49bb2977
38 changed files with 1656 additions and 57 deletions

View file

@ -91,6 +91,7 @@ classDiagram
} }
class StockBatchAllocation { class StockBatchAllocation {
+AllocationId id
+StockBatchId stockBatchId +StockBatchId stockBatchId
+Quantity allocatedQuantity +Quantity allocatedQuantity
} }
@ -163,6 +164,8 @@ classDiagram
StockBatch ..> StockBatchStatus StockBatch ..> StockBatchStatus
StockBatch ..> Quantity StockBatch ..> Quantity
StockBatchAllocation ..> AllocationId
StockMovement ..> StockMovementId StockMovement ..> StockMovementId
StockMovement ..> MovementType StockMovement ..> MovementType
StockMovement ..> MovementDirection StockMovement ..> MovementDirection
@ -208,6 +211,7 @@ Stock (Aggregate Root)
├── Priority (ReservationPriority: URGENT | NORMAL | LOW) ├── Priority (ReservationPriority: URGENT | NORMAL | LOW)
├── ReservedAt (Instant) ├── ReservedAt (Instant)
└── StockBatchAllocations[] (Entity) └── StockBatchAllocations[] (Entity)
├── AllocationId (VO)
├── StockBatchId (VO) - Zugeordnete Charge ├── StockBatchId (VO) - Zugeordnete Charge
└── AllocatedQuantity (VO) - Reservierte Menge aus dieser Charge └── AllocatedQuantity (VO) - Reservierte Menge aus dieser Charge
``` ```
@ -846,6 +850,15 @@ public sealed interface StockError {
record NegativeStockNotAllowed() implements StockError { record NegativeStockNotAllowed() implements StockError {
public String message() { return "Stock cannot go negative"; } 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 { public sealed interface StockMovementError {

View file

@ -0,0 +1,58 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.ReserveStockCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class ReserveStock {
private final StockRepository stockRepository;
public ReserveStock(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, Reservation> execute(ReserveStockCommand cmd) {
// 1. Stock laden
if (cmd.stockId() == null || cmd.stockId().isBlank()) {
return Result.failure(new StockError.StockNotFound("null"));
}
Stock stock;
switch (stockRepository.findById(StockId.of(cmd.stockId()))) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new StockError.StockNotFound(cmd.stockId()));
}
stock = opt.get();
}
}
// 2. Draft erstellen
var draft = new ReservationDraft(
cmd.referenceType(), cmd.referenceId(),
cmd.quantityAmount(), cmd.quantityUnit(),
cmd.priority()
);
// 3. Domain-Methode aufrufen
Reservation reservation;
switch (stock.reserve(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> reservation = val;
}
// 4. Speichern
switch (stockRepository.save(stock)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(reservation);
}
}

View file

@ -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
) {}

View file

@ -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);
}
}

View file

@ -0,0 +1,6 @@
package de.effigenix.domain.inventory;
public enum ReferenceType {
PRODUCTION_ORDER,
SALE_ORDER
}

View file

@ -0,0 +1,50 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Quantity;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
public class Reservation {
private final ReservationId id;
private final ReferenceType referenceType;
private final String referenceId;
private final Quantity quantity;
private final ReservationPriority priority;
private final Instant reservedAt;
private final List<StockBatchAllocation> allocations;
public Reservation(ReservationId id, ReferenceType referenceType, String referenceId,
Quantity quantity, ReservationPriority priority, Instant reservedAt,
List<StockBatchAllocation> allocations) {
this.id = Objects.requireNonNull(id);
this.referenceType = Objects.requireNonNull(referenceType);
this.referenceId = Objects.requireNonNull(referenceId);
this.quantity = Objects.requireNonNull(quantity);
this.priority = Objects.requireNonNull(priority);
this.reservedAt = Objects.requireNonNull(reservedAt);
this.allocations = List.copyOf(allocations);
}
public ReservationId id() { return id; }
public ReferenceType referenceType() { return referenceType; }
public String referenceId() { return referenceId; }
public Quantity quantity() { return quantity; }
public ReservationPriority priority() { return priority; }
public Instant reservedAt() { return reservedAt; }
public List<StockBatchAllocation> allocations() { return allocations; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Reservation other)) return false;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
public record ReservationDraft(
String referenceType,
String referenceId,
String quantityAmount,
String quantityUnit,
String priority
) {}

View file

@ -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);
}
}

View file

@ -0,0 +1,7 @@
package de.effigenix.domain.inventory;
public enum ReservationPriority {
URGENT,
NORMAL,
LOW
}

View file

@ -6,10 +6,14 @@ import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure; import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; 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 * - unblockBatch: BLOCKED AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED error
* - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today EXPIRED; BLOCKED untouched * - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today EXPIRED; BLOCKED untouched
* - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired EXPIRING_SOON; requires minimumShelfLife * - 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 * - 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 { public class Stock {
@ -39,6 +45,7 @@ public class Stock {
private MinimumLevel minimumLevel; private MinimumLevel minimumLevel;
private MinimumShelfLife minimumShelfLife; private MinimumShelfLife minimumShelfLife;
private final List<StockBatch> batches; private final List<StockBatch> batches;
private final List<Reservation> reservations;
private Stock( private Stock(
StockId id, StockId id,
@ -46,7 +53,8 @@ public class Stock {
StorageLocationId storageLocationId, StorageLocationId storageLocationId,
MinimumLevel minimumLevel, MinimumLevel minimumLevel,
MinimumShelfLife minimumShelfLife, MinimumShelfLife minimumShelfLife,
List<StockBatch> batches List<StockBatch> batches,
List<Reservation> reservations
) { ) {
this.id = id; this.id = id;
this.articleId = articleId; this.articleId = articleId;
@ -54,6 +62,7 @@ public class Stock {
this.minimumLevel = minimumLevel; this.minimumLevel = minimumLevel;
this.minimumShelfLife = minimumShelfLife; this.minimumShelfLife = minimumShelfLife;
this.batches = new ArrayList<>(batches); this.batches = new ArrayList<>(batches);
this.reservations = new ArrayList<>(reservations);
} }
/** /**
@ -102,7 +111,7 @@ public class Stock {
} }
return Result.success(new 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, StorageLocationId storageLocationId,
MinimumLevel minimumLevel, MinimumLevel minimumLevel,
MinimumShelfLife minimumShelfLife, MinimumShelfLife minimumShelfLife,
List<StockBatch> batches List<StockBatch> batches,
List<Reservation> reservations
) { ) {
return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches); return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches, reservations);
} }
// ==================== Update ==================== // ==================== Update ====================
@ -235,6 +245,114 @@ public class Stock {
return Result.success(null); return Result.success(null);
} }
// ==================== Reservation (FEFO) ====================
public Result<StockError, Reservation> reserve(ReservationDraft draft) {
// 1. ReferenceType validieren
if (draft.referenceType() == null || draft.referenceType().isBlank()) {
return Result.failure(new StockError.InvalidReferenceType("must not be blank"));
}
ReferenceType referenceType;
try {
referenceType = ReferenceType.valueOf(draft.referenceType());
} catch (IllegalArgumentException e) {
return Result.failure(new StockError.InvalidReferenceType(draft.referenceType()));
}
// 2. ReferenceId validieren
if (draft.referenceId() == null || draft.referenceId().isBlank()) {
return Result.failure(new StockError.InvalidReferenceId("must not be blank"));
}
// 3. Quantity validieren
Quantity requestedQuantity;
try {
BigDecimal amount = new BigDecimal(draft.quantityAmount());
UnitOfMeasure uom;
try {
uom = UnitOfMeasure.valueOf(draft.quantityUnit());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new StockError.InvalidQuantity("Invalid unit: " + draft.quantityUnit()));
}
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> {
return Result.failure(new StockError.InvalidQuantity(err.message()));
}
case Result.Success(var val) -> requestedQuantity = val;
}
} catch (NumberFormatException | NullPointerException e) {
return Result.failure(new StockError.InvalidQuantity("Invalid quantity amount: " + draft.quantityAmount()));
}
// 4. Priority validieren
if (draft.priority() == null || draft.priority().isBlank()) {
return Result.failure(new StockError.InvalidReservationPriority("must not be blank"));
}
ReservationPriority priority;
try {
priority = ReservationPriority.valueOf(draft.priority());
} catch (IllegalArgumentException e) {
return Result.failure(new StockError.InvalidReservationPriority(draft.priority()));
}
// 5. Verfügbare Chargen ermitteln (AVAILABLE + EXPIRING_SOON), sortiert nach expiryDate ASC (FEFO)
List<StockBatch> availableBatches = batches.stream()
.filter(StockBatch::isRemovable)
.sorted(Comparator.comparing(StockBatch::expiryDate, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
// 6. Bereits allokierte Mengen pro Batch berechnen
Map<StockBatchId, BigDecimal> allocatedPerBatch = new HashMap<>();
for (Reservation r : reservations) {
for (StockBatchAllocation alloc : r.allocations()) {
allocatedPerBatch.merge(alloc.stockBatchId(), alloc.allocatedQuantity().amount(), BigDecimal::add);
}
}
// 7. Verfügbare Menge prüfen
BigDecimal totalAvailable = BigDecimal.ZERO;
for (StockBatch batch : availableBatches) {
BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
if (free.compareTo(BigDecimal.ZERO) > 0) {
totalAvailable = totalAvailable.add(free);
}
}
if (totalAvailable.compareTo(requestedQuantity.amount()) < 0) {
return Result.failure(new StockError.InsufficientStock(
totalAvailable.toPlainString(), requestedQuantity.amount().toPlainString()));
}
// 8. FEFO-Allokation
List<StockBatchAllocation> allocations = new ArrayList<>();
BigDecimal remaining = requestedQuantity.amount();
for (StockBatch batch : availableBatches) {
if (remaining.compareTo(BigDecimal.ZERO) <= 0) break;
BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
if (free.compareTo(BigDecimal.ZERO) <= 0) continue;
BigDecimal toAllocate = remaining.min(free);
allocations.add(new StockBatchAllocation(
AllocationId.generate(),
batch.id(),
Quantity.reconstitute(toAllocate, requestedQuantity.uom())
));
remaining = remaining.subtract(toAllocate);
}
// 9. Reservation erstellen
var reservation = new Reservation(
ReservationId.generate(), referenceType, draft.referenceId(),
requestedQuantity, priority, Instant.now(), allocations
);
this.reservations.add(reservation);
return Result.success(reservation);
}
// ==================== Expiry Management ==================== // ==================== Expiry Management ====================
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) { public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
@ -273,10 +391,18 @@ public class Stock {
// ==================== Queries ==================== // ==================== Queries ====================
public BigDecimal availableQuantity() { public BigDecimal availableQuantity() {
return batches.stream() BigDecimal gross = batches.stream()
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
.map(b -> b.quantity().amount()) .map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add); .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() { public boolean isBelowMinimumLevel() {
@ -305,6 +431,7 @@ public class Stock {
public MinimumLevel minimumLevel() { return minimumLevel; } public MinimumLevel minimumLevel() { return minimumLevel; }
public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; } public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; }
public List<StockBatch> batches() { return Collections.unmodifiableList(batches); } public List<StockBatch> batches() { return Collections.unmodifiableList(batches); }
public List<Reservation> reservations() { return Collections.unmodifiableList(reservations); }
// ==================== Helpers ==================== // ==================== Helpers ====================

View file

@ -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);
}
}

View file

@ -80,6 +80,31 @@ public sealed interface StockError {
@Override public String message() { return "Batch " + id + " is not blocked"; } @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 { record InvalidFilterCombination(String message) implements StockError {
@Override public String code() { return "INVALID_FILTER_COMBINATION"; } @Override public String code() { return "INVALID_FILTER_COMBINATION"; }
} }

View file

@ -10,6 +10,7 @@ import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.ReserveStock;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.DeactivateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation;
@ -100,6 +101,11 @@ public class InventoryUseCaseConfiguration {
return new UnblockStockBatch(stockRepository, auditLogger); return new UnblockStockBatch(stockRepository, auditLogger);
} }
@Bean
public ReserveStock reserveStock(StockRepository stockRepository) {
return new ReserveStock(stockRepository);
}
@Bean @Bean
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository); return new CheckStockExpiry(stockRepository);

View file

@ -0,0 +1,76 @@
package de.effigenix.infrastructure.inventory.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "reservations")
public class ReservationEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stock_id", nullable = false)
private StockEntity stock;
@Column(name = "reference_type", nullable = false, length = 30)
private String referenceType;
@Column(name = "reference_id", nullable = false, length = 100)
private String referenceId;
@Column(name = "quantity_amount", nullable = false, precision = 19, scale = 6)
private BigDecimal quantityAmount;
@Column(name = "quantity_unit", nullable = false, length = 20)
private String quantityUnit;
@Column(name = "priority", nullable = false, length = 20)
private String priority;
@Column(name = "reserved_at", nullable = false)
private Instant reservedAt;
@OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<StockBatchAllocationEntity> allocations = new ArrayList<>();
protected ReservationEntity() {}
public ReservationEntity(String id, StockEntity stock, String referenceType, String referenceId,
BigDecimal quantityAmount, String quantityUnit, String priority,
Instant reservedAt) {
this.id = id;
this.stock = stock;
this.referenceType = referenceType;
this.referenceId = referenceId;
this.quantityAmount = quantityAmount;
this.quantityUnit = quantityUnit;
this.priority = priority;
this.reservedAt = reservedAt;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public StockEntity getStock() { return stock; }
public void setStock(StockEntity stock) { this.stock = stock; }
public String getReferenceType() { return referenceType; }
public void setReferenceType(String referenceType) { this.referenceType = referenceType; }
public String getReferenceId() { return referenceId; }
public void setReferenceId(String referenceId) { this.referenceId = referenceId; }
public BigDecimal getQuantityAmount() { return quantityAmount; }
public void setQuantityAmount(BigDecimal quantityAmount) { this.quantityAmount = quantityAmount; }
public String getQuantityUnit() { return quantityUnit; }
public void setQuantityUnit(String quantityUnit) { this.quantityUnit = quantityUnit; }
public String getPriority() { return priority; }
public void setPriority(String priority) { this.priority = priority; }
public Instant getReservedAt() { return reservedAt; }
public void setReservedAt(Instant reservedAt) { this.reservedAt = reservedAt; }
public List<StockBatchAllocationEntity> getAllocations() { return allocations; }
public void setAllocations(List<StockBatchAllocationEntity> allocations) { this.allocations = allocations; }
}

View file

@ -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; }
}

View file

@ -32,6 +32,9 @@ public class StockEntity {
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) @OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private List<StockBatchEntity> batches = new ArrayList<>(); private List<StockBatchEntity> batches = new ArrayList<>();
@OneToMany(mappedBy = "stock", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ReservationEntity> reservations = new ArrayList<>();
public StockEntity() {} public StockEntity() {}
// ==================== Getters & Setters ==================== // ==================== Getters & Setters ====================
@ -56,4 +59,7 @@ public class StockEntity {
public List<StockBatchEntity> getBatches() { return batches; } public List<StockBatchEntity> getBatches() { return batches; }
public void setBatches(List<StockBatchEntity> batches) { this.batches = batches; } public void setBatches(List<StockBatchEntity> batches) { this.batches = batches; }
public List<ReservationEntity> getReservations() { return reservations; }
public void setReservations(List<ReservationEntity> reservations) { this.reservations = reservations; }
} }

View file

@ -4,6 +4,8 @@ import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure; 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.StockBatchEntity;
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity; import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -34,6 +36,11 @@ public class StockMapper {
.collect(Collectors.toList()); .collect(Collectors.toList());
entity.setBatches(batchEntities); entity.setBatches(batchEntities);
List<ReservationEntity> reservationEntities = stock.reservations().stream()
.map(r -> toReservationEntity(r, entity))
.collect(Collectors.toList());
entity.setReservations(reservationEntities);
return entity; return entity;
} }
@ -56,13 +63,18 @@ public class StockMapper {
.map(this::toDomainBatch) .map(this::toDomainBatch)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<Reservation> reservations = entity.getReservations().stream()
.map(this::toDomainReservation)
.collect(Collectors.toList());
return Stock.reconstitute( return Stock.reconstitute(
StockId.of(entity.getId()), StockId.of(entity.getId()),
ArticleId.of(entity.getArticleId()), ArticleId.of(entity.getArticleId()),
StorageLocationId.of(entity.getStorageLocationId()), StorageLocationId.of(entity.getStorageLocationId()),
minimumLevel, minimumLevel,
minimumShelfLife, minimumShelfLife,
batches batches,
reservations
); );
} }
@ -93,4 +105,64 @@ public class StockMapper {
entity.getReceivedAt() entity.getReceivedAt()
); );
} }
private ReservationEntity toReservationEntity(Reservation reservation, StockEntity stockEntity) {
var entity = new ReservationEntity(
reservation.id().value(),
stockEntity,
reservation.referenceType().name(),
reservation.referenceId(),
reservation.quantity().amount(),
reservation.quantity().uom().name(),
reservation.priority().name(),
reservation.reservedAt()
);
List<StockBatchAllocationEntity> allocationEntities = reservation.allocations().stream()
.map(a -> toAllocationEntity(a, entity))
.collect(Collectors.toList());
entity.setAllocations(allocationEntities);
return entity;
}
private StockBatchAllocationEntity toAllocationEntity(StockBatchAllocation allocation, ReservationEntity reservationEntity) {
return new StockBatchAllocationEntity(
allocation.id().value(),
reservationEntity,
allocation.stockBatchId().value(),
allocation.allocatedQuantity().amount(),
allocation.allocatedQuantity().uom().name()
);
}
private Reservation toDomainReservation(ReservationEntity entity) {
List<StockBatchAllocation> allocations = entity.getAllocations().stream()
.map(this::toDomainAllocation)
.collect(Collectors.toList());
return new Reservation(
ReservationId.of(entity.getId()),
ReferenceType.valueOf(entity.getReferenceType()),
entity.getReferenceId(),
Quantity.reconstitute(
entity.getQuantityAmount(),
UnitOfMeasure.valueOf(entity.getQuantityUnit())
),
ReservationPriority.valueOf(entity.getPriority()),
entity.getReservedAt(),
allocations
);
}
private StockBatchAllocation toDomainAllocation(StockBatchAllocationEntity entity) {
return new StockBatchAllocation(
AllocationId.of(entity.getId()),
StockBatchId.of(entity.getStockBatchId()),
Quantity.reconstitute(
entity.getAllocatedQuantityAmount(),
UnitOfMeasure.valueOf(entity.getAllocatedQuantityUnit())
)
);
}
} }

View file

@ -7,12 +7,14 @@ import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.ReserveStock;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.AddStockBatchCommand;
import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.BlockStockBatchCommand;
import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.CreateStockCommand;
import de.effigenix.application.inventory.command.RemoveStockBatchCommand; 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.UnblockStockBatchCommand;
import de.effigenix.application.inventory.command.UpdateStockCommand; import de.effigenix.application.inventory.command.UpdateStockCommand;
import de.effigenix.domain.inventory.StockError; 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.CreateStockRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockResponse; import de.effigenix.infrastructure.inventory.web.dto.CreateStockResponse;
import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest; 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.StockBatchResponse;
import de.effigenix.infrastructure.inventory.web.dto.StockResponse; import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
import de.effigenix.infrastructure.inventory.web.dto.UpdateStockRequest; import de.effigenix.infrastructure.inventory.web.dto.UpdateStockRequest;
@ -55,11 +59,13 @@ public class StockController {
private final RemoveStockBatch removeStockBatch; private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch; private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch; private final UnblockStockBatch unblockStockBatch;
private final ReserveStock reserveStock;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum, ListStocksBelowMinimum listStocksBelowMinimum,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
ReserveStock reserveStock) {
this.createStock = createStock; this.createStock = createStock;
this.updateStock = updateStock; this.updateStock = updateStock;
this.getStock = getStock; this.getStock = getStock;
@ -69,6 +75,7 @@ public class StockController {
this.removeStockBatch = removeStockBatch; this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch; this.blockStockBatch = blockStockBatch;
this.unblockStockBatch = unblockStockBatch; this.unblockStockBatch = unblockStockBatch;
this.reserveStock = reserveStock;
} }
@GetMapping @GetMapping
@ -255,6 +262,30 @@ public class StockController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@PostMapping("/{stockId}/reservations")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<ReservationResponse> reserveStock(
@PathVariable String stockId,
@Valid @RequestBody ReserveStockRequest request,
Authentication authentication
) {
logger.info("Reserving stock {} by actor: {}", stockId, authentication.getName());
var cmd = new ReserveStockCommand(
stockId, request.referenceType(), request.referenceId(),
request.quantityAmount(), request.quantityUnit(), request.priority()
);
var result = reserveStock.execute(cmd);
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
logger.info("Reservation created: {} for stock {}", result.unsafeGetValue().id().value(), stockId);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ReservationResponse.from(result.unsafeGetValue()));
}
public static class StockDomainErrorException extends RuntimeException { public static class StockDomainErrorException extends RuntimeException {
private final StockError error; private final StockError error;

View file

@ -0,0 +1,34 @@
package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.Reservation;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record ReservationResponse(
String id,
String referenceType,
String referenceId,
BigDecimal quantityAmount,
String quantityUnit,
String priority,
Instant reservedAt,
List<StockBatchAllocationResponse> allocations
) {
public static ReservationResponse from(Reservation reservation) {
return new ReservationResponse(
reservation.id().value(),
reservation.referenceType().name(),
reservation.referenceId(),
reservation.quantity().amount(),
reservation.quantity().uom().name(),
reservation.priority().name(),
reservation.reservedAt(),
reservation.allocations().stream()
.map(StockBatchAllocationResponse::from)
.toList()
);
}
}

View file

@ -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
) {}

View file

@ -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()
);
}
}

View file

@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity"}) @Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity", "reservations"})
public record StockResponse( public record StockResponse(
String id, String id,
String articleId, String articleId,
@ -16,7 +16,8 @@ public record StockResponse(
List<StockBatchResponse> batches, List<StockBatchResponse> batches,
BigDecimal totalQuantity, BigDecimal totalQuantity,
@Schema(nullable = true) String quantityUnit, @Schema(nullable = true) String quantityUnit,
BigDecimal availableQuantity BigDecimal availableQuantity,
List<ReservationResponse> reservations
) { ) {
public static StockResponse from(Stock stock) { public static StockResponse from(Stock stock) {
@ -47,6 +48,10 @@ public record StockResponse(
? null ? null
: stock.batches().getFirst().quantity().uom().name(); : stock.batches().getFirst().quantity().uom().name();
List<ReservationResponse> reservationResponses = stock.reservations().stream()
.map(ReservationResponse::from)
.toList();
return new StockResponse( return new StockResponse(
stock.id().value(), stock.id().value(),
stock.articleId().value(), stock.articleId().value(),
@ -56,7 +61,8 @@ public record StockResponse(
batchResponses, batchResponses,
totalQuantity, totalQuantity,
quantityUnit, quantityUnit,
availableQuantity availableQuantity,
reservationResponses
); );
} }
} }

View file

@ -26,12 +26,14 @@ public final class InventoryErrorHttpStatusMapper {
return switch (error) { return switch (error) {
case StockError.StockNotFound e -> 404; case StockError.StockNotFound e -> 404;
case StockError.BatchNotFound e -> 404; case StockError.BatchNotFound e -> 404;
case StockError.ReservationNotFound e -> 404;
case StockError.DuplicateStock e -> 409; case StockError.DuplicateStock e -> 409;
case StockError.DuplicateBatchReference e -> 409; case StockError.DuplicateBatchReference e -> 409;
case StockError.NegativeStockNotAllowed e -> 409; case StockError.NegativeStockNotAllowed e -> 409;
case StockError.BatchNotAvailable e -> 409; case StockError.BatchNotAvailable e -> 409;
case StockError.BatchAlreadyBlocked e -> 409; case StockError.BatchAlreadyBlocked e -> 409;
case StockError.BatchNotBlocked e -> 409; case StockError.BatchNotBlocked e -> 409;
case StockError.InsufficientStock e -> 409;
case StockError.InvalidMinimumLevel e -> 400; case StockError.InvalidMinimumLevel e -> 400;
case StockError.InvalidMinimumShelfLife e -> 400; case StockError.InvalidMinimumShelfLife e -> 400;
case StockError.InvalidArticleId e -> 400; case StockError.InvalidArticleId e -> 400;
@ -40,6 +42,9 @@ public final class InventoryErrorHttpStatusMapper {
case StockError.InvalidQuantity e -> 400; case StockError.InvalidQuantity e -> 400;
case StockError.InvalidExpiryDate e -> 400; case StockError.InvalidExpiryDate e -> 400;
case StockError.InvalidFilterCombination 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.Unauthorized e -> 403;
case StockError.RepositoryFailure e -> 500; case StockError.RepositoryFailure e -> 500;
}; };

View file

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="024-create-reservations-table" author="effigenix">
<createTable tableName="reservations">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="stock_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="reference_type" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="reference_id" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="quantity_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="quantity_unit" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="priority" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="reserved_at" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="reservations" baseColumnNames="stock_id"
referencedTableName="stocks" referencedColumnNames="id"
constraintName="fk_reservations_stock_id"
onDelete="CASCADE"/>
<sql>
ALTER TABLE reservations ADD CONSTRAINT chk_reservation_reference_type
CHECK (reference_type IN ('PRODUCTION_ORDER', 'SALE_ORDER'));
</sql>
<sql>
ALTER TABLE reservations ADD CONSTRAINT chk_reservation_priority
CHECK (priority IN ('URGENT', 'NORMAL', 'LOW'));
</sql>
<createIndex tableName="reservations" indexName="idx_reservations_stock_id">
<column name="stock_id"/>
</createIndex>
<createIndex tableName="reservations" indexName="idx_reservations_reference">
<column name="reference_type"/>
<column name="reference_id"/>
</createIndex>
</changeSet>
<changeSet id="024-create-stock-batch-allocations-table" author="effigenix">
<createTable tableName="stock_batch_allocations">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="reservation_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="stock_batch_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="allocated_quantity_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="allocated_quantity_unit" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="stock_batch_allocations" baseColumnNames="reservation_id"
referencedTableName="reservations" referencedColumnNames="id"
constraintName="fk_allocations_reservation_id"
onDelete="CASCADE"/>
<addForeignKeyConstraint
baseTableName="stock_batch_allocations" baseColumnNames="stock_batch_id"
referencedTableName="stock_batches" referencedColumnNames="id"
constraintName="fk_allocations_stock_batch_id"
onDelete="RESTRICT"/>
<createIndex tableName="stock_batch_allocations" indexName="idx_allocations_reservation_id">
<column name="reservation_id"/>
</createIndex>
<createIndex tableName="stock_batch_allocations" indexName="idx_allocations_stock_batch_id">
<column name="stock_batch_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -30,5 +30,6 @@
<include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/> <include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/>
<include file="db/changelog/changes/024-create-production-orders-table.xml"/> <include file="db/changelog/changes/024-create-production-orders-table.xml"/>
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/> <include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
<include file="db/changelog/changes/026-create-reservations-schema.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -41,7 +41,7 @@ class AddStockBatchTest {
StockId.of("stock-1"), StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, List.of() null, null, List.of(), List.of()
); );
validCommand = new AddStockBatchCommand( validCommand = new AddStockBatchCommand(
@ -123,7 +123,7 @@ class AddStockBatchTest {
LocalDate.of(2026, 12, 31), LocalDate.of(2026, 12, 31),
StockBatchStatus.AVAILABLE, StockBatchStatus.AVAILABLE,
Instant.now() Instant.now()
)) )), List.of()
); );
when(stockRepository.findById(StockId.of("stock-1"))) when(stockRepository.findById(StockId.of("stock-1")))

View file

@ -58,7 +58,7 @@ class BlockStockBatchTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue"); validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue");
@ -153,7 +153,7 @@ class BlockStockBatchTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(blockedBatch)) new ArrayList<>(List.of(blockedBatch)), List.of()
); );
when(stockRepository.findById(StockId.of("stock-1"))) when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stock))); .thenReturn(Result.success(Optional.of(stock)));

View file

@ -60,7 +60,7 @@ class CheckStockExpiryTest {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), null, new MinimumShelfLife(30),
new ArrayList<>(List.of(expiredBatch, expiringSoonBatch)) new ArrayList<>(List.of(expiredBatch, expiringSoonBatch)), List.of()
); );
stockRepository.addStock(stock); stockRepository.addStock(stock);
@ -102,7 +102,7 @@ class CheckStockExpiryTest {
); );
var stock1 = Stock.reconstitute( var stock1 = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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( var batch2 = StockBatch.reconstitute(
@ -113,7 +113,7 @@ class CheckStockExpiryTest {
); );
var stock2 = Stock.reconstitute( var stock2 = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-2"), StorageLocationId.of("location-2"), 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); stockRepository.addStock(stock1);
@ -146,7 +146,7 @@ class CheckStockExpiryTest {
// No minimumShelfLife expiringSoon should not be marked // No minimumShelfLife expiringSoon should not be marked
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); stockRepository.addStock(stock);
@ -176,7 +176,7 @@ class CheckStockExpiryTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); stockRepository.addStock(stock);
@ -213,7 +213,7 @@ class CheckStockExpiryTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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.addStock(stock);
stockRepository.failOnSave = true; stockRepository.failOnSave = true;

View file

@ -80,7 +80,7 @@ class DeactivateStorageLocationTest {
var locationId = StorageLocationId.of("loc-1"); var locationId = StorageLocationId.of("loc-1");
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"), StockId.of("stock-1"), ArticleId.of("article-1"),
locationId, null, null, List.of() locationId, null, null, List.of(), List.of()
); );
when(storageLocationRepository.findById(locationId)) when(storageLocationRepository.findById(locationId))
.thenReturn(Result.success(Optional.of(activeLocation("loc-1")))); .thenReturn(Result.success(Optional.of(activeLocation("loc-1"))));

View file

@ -34,7 +34,7 @@ class GetStockTest {
StockId.of("stock-1"), StockId.of("stock-1"),
ArticleId.of("article-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, List.of() null, null, List.of(), List.of()
); );
} }

View file

@ -103,7 +103,7 @@ class ListStocksBelowMinimumTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal(minimumAmount), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal(minimumAmount), UnitOfMeasure.KILOGRAM));
return Stock.reconstitute( return Stock.reconstitute(
StockId.generate(), ArticleId.of("article-" + availableAmount), StorageLocationId.of("location-1"), 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()
); );
} }

View file

@ -34,14 +34,14 @@ class ListStocksTest {
StockId.of("stock-1"), StockId.of("stock-1"),
ArticleId.of("article-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, List.of() null, null, List.of(), List.of()
); );
stock2 = Stock.reconstitute( stock2 = Stock.reconstitute(
StockId.of("stock-2"), StockId.of("stock-2"),
ArticleId.of("article-2"), ArticleId.of("article-2"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, List.of() null, null, List.of(), List.of()
); );
} }

View file

@ -54,7 +54,7 @@ class RemoveStockBatchTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
validCommand = new RemoveStockBatchCommand("stock-1", "batch-1", "5", "KILOGRAM"); validCommand = new RemoveStockBatchCommand("stock-1", "batch-1", "5", "KILOGRAM");

View file

@ -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());
}
}

View file

@ -58,7 +58,7 @@ class UnblockStockBatchTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
validCommand = new UnblockStockBatchCommand("stock-1", "batch-1"); validCommand = new UnblockStockBatchCommand("stock-1", "batch-1");
@ -153,7 +153,7 @@ class UnblockStockBatchTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(availableBatch)) new ArrayList<>(List.of(availableBatch)), List.of()
); );
when(stockRepository.findById(StockId.of("stock-1"))) when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stock))); .thenReturn(Result.success(Optional.of(stock)));

View file

@ -37,7 +37,7 @@ class UpdateStockTest {
de.effigenix.domain.masterdata.ArticleId.of("article-1"), de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
List.of() List.of(), List.of()
); );
} }
@ -105,7 +105,7 @@ class UpdateStockTest {
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
MinimumLevel.of("25", "KILOGRAM").unsafeGetValue(), MinimumLevel.of("25", "KILOGRAM").unsafeGetValue(),
MinimumShelfLife.of(7).unsafeGetValue(), MinimumShelfLife.of(7).unsafeGetValue(),
List.of() List.of(), List.of()
); );
when(stockRepository.findById(StockId.of("stock-1"))) when(stockRepository.findById(StockId.of("stock-1")))
.thenReturn(Result.success(Optional.of(stockWithParams))); .thenReturn(Result.success(Optional.of(stockWithParams)));

View file

@ -486,7 +486,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(quantity); var minimumLevel = new MinimumLevel(quantity);
var minimumShelfLife = new MinimumShelfLife(30); 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.id()).isEqualTo(id);
assertThat(stock.articleId()).isEqualTo(articleId); assertThat(stock.articleId()).isEqualTo(articleId);
@ -503,7 +503,7 @@ class StockTest {
var articleId = ArticleId.of("article-1"); var articleId = ArticleId.of("article-1");
var locationId = StorageLocationId.of("location-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.minimumLevel()).isNull();
assertThat(stock.minimumShelfLife()).isNull(); assertThat(stock.minimumShelfLife()).isNull();
@ -520,8 +520,8 @@ class StockTest {
@DisplayName("should be equal if same ID") @DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() { void shouldBeEqualBySameId() {
var id = StockId.generate(); var id = StockId.generate();
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), 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()); var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of(), List.of());
assertThat(stock1).isEqualTo(stock2); assertThat(stock1).isEqualTo(stock2);
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode()); assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
@ -751,7 +751,7 @@ class StockTest {
ArticleId.of("article-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
var batchId = stock.batches().getFirst().id(); var batchId = stock.batches().getFirst().id();
@ -777,7 +777,7 @@ class StockTest {
ArticleId.of("article-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
var batchId = stock.batches().getFirst().id(); var batchId = stock.batches().getFirst().id();
@ -918,7 +918,7 @@ class StockTest {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, 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); var result = stock.markExpiredBatches(today);
@ -978,7 +978,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1002,7 +1002,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1040,7 +1040,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1064,7 +1064,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1088,7 +1088,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1112,7 +1112,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1147,7 +1147,7 @@ class StockTest {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), 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); var result = stock.markExpiringSoonBatches(today);
@ -1173,7 +1173,7 @@ class StockTest {
); );
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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); var result = stock.markExpiringSoonBatches(today);
@ -1219,7 +1219,7 @@ class StockTest {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, 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")); assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
@ -1250,7 +1250,7 @@ class StockTest {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(blocked, expired)) new ArrayList<>(List.of(blocked, expired)), List.of()
); );
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO); assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
@ -1268,7 +1268,7 @@ class StockTest {
void shouldReturnFalseWithoutMinimumLevel() { void shouldReturnFalseWithoutMinimumLevel() {
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, List.of() null, null, List.of(), List.of()
); );
assertThat(stock.isBelowMinimumLevel()).isFalse(); assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1286,7 +1286,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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(); assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1304,7 +1304,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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(); assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1322,7 +1322,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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(); assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1334,7 +1334,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, List.of() minimumLevel, null, List.of(), List.of()
); );
assertThat(stock.isBelowMinimumLevel()).isTrue(); assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1346,7 +1346,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, List.of() minimumLevel, null, List.of(), List.of()
); );
assertThat(stock.isBelowMinimumLevel()).isFalse(); assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1370,7 +1370,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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(); assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1394,13 +1394,400 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)); var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute( var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), 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(); 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 ==================== // ==================== Helpers ====================
private Stock createValidStock() { private Stock createValidStock() {
@ -1426,7 +1813,7 @@ class StockTest {
ArticleId.of("article-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
null, null, null, null,
new ArrayList<>(List.of(batch)) new ArrayList<>(List.of(batch)), List.of()
); );
} }
} }

View file

@ -4,6 +4,7 @@ import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest; import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; 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.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach; 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 ==================== // ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception { private String createStorageLocation() throws Exception {