1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:49: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

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

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"; }
}
record InsufficientStock(String available, String requested) implements StockError {
@Override public String code() { return "INSUFFICIENT_STOCK"; }
@Override public String message() { return "Insufficient stock: available " + available + ", requested " + requested; }
}
record InvalidReferenceType(String value) implements StockError {
@Override public String code() { return "INVALID_REFERENCE_TYPE"; }
@Override public String message() { return "Invalid reference type: " + value; }
}
record InvalidReferenceId(String reason) implements StockError {
@Override public String code() { return "INVALID_REFERENCE_ID"; }
@Override public String message() { return "Invalid reference ID: " + reason; }
}
record InvalidReservationPriority(String value) implements StockError {
@Override public String code() { return "INVALID_RESERVATION_PRIORITY"; }
@Override public String message() { return "Invalid reservation priority: " + value; }
}
record ReservationNotFound(String id) implements StockError {
@Override public String code() { return "RESERVATION_NOT_FOUND"; }
@Override public String message() { return "Reservation not found: " + id; }
}
record InvalidFilterCombination(String message) implements StockError {
@Override public String code() { return "INVALID_FILTER_COMBINATION"; }
}

View file

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

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

View file

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

View file

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

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

View file

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

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/024-create-production-orders-table.xml"/>
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
<include file="db/changelog/changes/026-create-reservations-schema.xml"/>
</databaseChangeLog>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -486,7 +486,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(quantity);
var minimumShelfLife = new MinimumShelfLife(30);
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of());
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of(), List.of());
assertThat(stock.id()).isEqualTo(id);
assertThat(stock.articleId()).isEqualTo(articleId);
@ -503,7 +503,7 @@ class StockTest {
var articleId = ArticleId.of("article-1");
var locationId = StorageLocationId.of("location-1");
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of());
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of(), List.of());
assertThat(stock.minimumLevel()).isNull();
assertThat(stock.minimumShelfLife()).isNull();
@ -520,8 +520,8 @@ class StockTest {
@DisplayName("should be equal if same ID")
void shouldBeEqualBySameId() {
var id = StockId.generate();
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of());
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of());
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of(), List.of());
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of(), List.of());
assertThat(stock1).isEqualTo(stock2);
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
@ -751,7 +751,7 @@ class StockTest {
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch))
new ArrayList<>(List.of(batch)), List.of()
);
var batchId = stock.batches().getFirst().id();
@ -777,7 +777,7 @@ class StockTest {
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30),
new ArrayList<>(List.of(batch))
new ArrayList<>(List.of(batch)), List.of()
);
var batchId = stock.batches().getFirst().id();
@ -918,7 +918,7 @@ class StockTest {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable))
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable)), List.of()
);
var result = stock.markExpiredBatches(today);
@ -978,7 +978,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1002,7 +1002,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1040,7 +1040,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1064,7 +1064,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1088,7 +1088,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1112,7 +1112,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1147,7 +1147,7 @@ class StockTest {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30),
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold))
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1173,7 +1173,7 @@ class StockTest {
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
);
var result = stock.markExpiringSoonBatches(today);
@ -1219,7 +1219,7 @@ class StockTest {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(available, expiringSoon, blocked, expired))
new ArrayList<>(List.of(available, expiringSoon, blocked, expired)), List.of()
);
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
@ -1250,7 +1250,7 @@ class StockTest {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(blocked, expired))
new ArrayList<>(List.of(blocked, expired)), List.of()
);
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
@ -1268,7 +1268,7 @@ class StockTest {
void shouldReturnFalseWithoutMinimumLevel() {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, List.of()
null, null, List.of(), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1286,7 +1286,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, new ArrayList<>(List.of(batch))
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1304,7 +1304,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, new ArrayList<>(List.of(batch))
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1322,7 +1322,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, new ArrayList<>(List.of(batch))
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1334,7 +1334,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, List.of()
minimumLevel, null, List.of(), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1346,7 +1346,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, List.of()
minimumLevel, null, List.of(), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
@ -1370,7 +1370,7 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, new ArrayList<>(List.of(blocked, expired))
minimumLevel, null, new ArrayList<>(List.of(blocked, expired)), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
@ -1394,13 +1394,400 @@ class StockTest {
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch))
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
}
// ==================== reserve (FEFO) ====================
@Nested
@DisplayName("reserve()")
class Reserve {
@Test
@DisplayName("should reserve from single batch")
void shouldReserveFromSingleBatch() {
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var batchId = stock.batches().getFirst().id();
var draft = new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
var result = stock.reserve(draft);
assertThat(result.isSuccess()).isTrue();
var reservation = result.unsafeGetValue();
assertThat(reservation.id()).isNotNull();
assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
assertThat(reservation.referenceId()).isEqualTo("PO-001");
assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("5"));
assertThat(reservation.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
assertThat(reservation.reservedAt()).isNotNull();
assertThat(reservation.allocations()).hasSize(1);
assertThat(reservation.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
assertThat(reservation.allocations().getFirst().allocatedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("5"));
}
@Test
@DisplayName("should allocate FEFO earliest expiry first")
void shouldAllocateFefoEarliestFirst() {
var earlyBatch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-EARLY", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
);
var lateBatch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-LATE", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(lateBatch, earlyBatch)), List.of()
);
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
assertThat(result.isSuccess()).isTrue();
var allocs = result.unsafeGetValue().allocations();
assertThat(allocs).hasSize(1);
assertThat(allocs.getFirst().stockBatchId()).isEqualTo(earlyBatch.id());
assertThat(allocs.getFirst().allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("8"));
}
@Test
@DisplayName("should split allocation across multiple batches (FEFO)")
void shouldSplitAcrossMultipleBatches() {
var batch1 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
);
var batch2 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 9, 30), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
);
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
assertThat(result.isSuccess()).isTrue();
var allocs = result.unsafeGetValue().allocations();
assertThat(allocs).hasSize(2);
assertThat(allocs.get(0).stockBatchId()).isEqualTo(batch1.id());
assertThat(allocs.get(0).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("6"));
assertThat(allocs.get(1).stockBatchId()).isEqualTo(batch2.id());
assertThat(allocs.get(1).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("4"));
}
@Test
@DisplayName("should include EXPIRING_SOON batches in allocation")
void shouldIncludeExpiringSoonBatches() {
var expiringSoon = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(expiringSoon)), List.of()
);
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.URGENT);
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
}
@Test
@DisplayName("should skip BLOCKED and EXPIRED batches")
void shouldSkipBlockedAndExpiredBatches() {
var blocked = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-BLOCKED", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
);
var expired = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-EXPIRED", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
);
var available = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-AVAIL", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(blocked, expired, available)), List.of()
);
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
assertThat(result.unsafeGetValue().allocations().getFirst().stockBatchId()).isEqualTo(available.id());
}
@Test
@DisplayName("should fail with InsufficientStock when not enough available")
void shouldFailWhenInsufficientStock() {
var stock = createStockWithBatchAndExpiry("5", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
}
@Test
@DisplayName("should fail with InsufficientStock when all stock is already reserved")
void shouldFailWhenAllStockAlreadyReserved() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var existingReservation = new Reservation(
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
ReservationPriority.NORMAL, Instant.now(),
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)))
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
);
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
}
@Test
@DisplayName("should consider existing reservations when allocating")
void shouldConsiderExistingReservations() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var existingReservation = new Reservation(
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
ReservationPriority.NORMAL, Instant.now(),
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM)))
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
);
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "5", "KILOGRAM", "NORMAL"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("5"));
}
@Test
@DisplayName("should update availableQuantity after reservation")
void shouldUpdateAvailableQuantityAfterReservation() {
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
}
@Test
@DisplayName("should add reservation to reservations list")
void shouldAddReservationToList() {
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
assertThat(stock.reservations()).isEmpty();
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT"));
assertThat(stock.reservations()).hasSize(2);
}
@Test
@DisplayName("should fail with InsufficientStock when stock has no batches")
void shouldFailWhenNoBatches() {
var stock = createValidStock();
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "1", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
}
@Test
@DisplayName("should reserve exact full amount of batch")
void shouldReserveExactFullAmount() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("10"));
}
// ==================== Validation Edge Cases ====================
@Test
@DisplayName("should fail with InvalidReferenceType when null")
void shouldFailWhenReferenceTypeNull() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft(null, "PO-001", "5", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
}
@Test
@DisplayName("should fail with InvalidReferenceType when invalid value")
void shouldFailWhenReferenceTypeInvalid() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
}
@Test
@DisplayName("should fail with InvalidReferenceId when referenceId is blank")
void shouldFailWhenReferenceIdBlank() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "", "5", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
}
@Test
@DisplayName("should fail with InvalidReferenceId when referenceId is null")
void shouldFailWhenReferenceIdNull() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", null, "5", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
}
@Test
@DisplayName("should fail with InvalidQuantity when amount is not a number")
void shouldFailWhenQuantityNotNumber() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "abc", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail with InvalidQuantity when amount is negative")
void shouldFailWhenQuantityNegative() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail with InvalidQuantity when unit is invalid")
void shouldFailWhenUnitInvalid() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "INVALID_UNIT", "NORMAL"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
}
@Test
@DisplayName("should fail with InvalidReservationPriority when null")
void shouldFailWhenPriorityNull() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", null));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
}
@Test
@DisplayName("should fail with InvalidReservationPriority when invalid value")
void shouldFailWhenPriorityInvalid() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
}
@Test
@DisplayName("should accept LOW priority")
void shouldAcceptLowPriority() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "LOW"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.LOW);
}
}
// ==================== Helpers ====================
private Stock createValidStock() {
@ -1426,7 +1813,7 @@ class StockTest {
ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(batch))
new ArrayList<>(List.of(batch)), List.of()
);
}
}

View file

@ -4,6 +4,7 @@ import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.inventory.web.dto.ReserveStockRequest;
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
@ -982,6 +983,220 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
}
}
// ==================== Bestand reservieren (FEFO) ====================
@Nested
@DisplayName("POST /{stockId}/reservations Bestand reservieren")
class ReserveStockEndpoint {
@Test
@DisplayName("Reservierung mit gültigen Daten → 201")
void reserveStock_valid_returns201() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.referenceType").value("PRODUCTION_ORDER"))
.andExpect(jsonPath("$.referenceId").value("PO-001"))
.andExpect(jsonPath("$.quantityAmount").value(5))
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.priority").value("NORMAL"))
.andExpect(jsonPath("$.reservedAt").isNotEmpty())
.andExpect(jsonPath("$.allocations").isArray())
.andExpect(jsonPath("$.allocations.length()").value(1))
.andExpect(jsonPath("$.allocations[0].stockBatchId").isNotEmpty())
.andExpect(jsonPath("$.allocations[0].allocatedQuantityAmount").value(5));
}
@Test
@DisplayName("SALE_ORDER mit URGENT-Priorität → 201")
void reserveStock_saleOrderUrgent_returns201() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.referenceType").value("SALE_ORDER"))
.andExpect(jsonPath("$.priority").value("URGENT"));
}
@Test
@DisplayName("Reservierung sichtbar bei GET Stock → 200")
void reserveStock_visibleOnGet() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.reservations").isArray())
.andExpect(jsonPath("$.reservations.length()").value(1))
.andExpect(jsonPath("$.reservations[0].referenceId").value("PO-001"))
.andExpect(jsonPath("$.availableQuantity").value(5));
}
@Test
@DisplayName("Ungenügender Bestand → 409")
void reserveStock_insufficientStock_returns409() throws Exception {
String stockId = createStock();
addBatchToStock(stockId); // 10 KILOGRAM
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "999", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
}
@Test
@DisplayName("Ungültiger ReferenceType → 400")
void reserveStock_invalidReferenceType_returns400() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_REFERENCE_TYPE"));
}
@Test
@DisplayName("Ungültige Priorität → 400")
void reserveStock_invalidPriority_returns400() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_RESERVATION_PRIORITY"));
}
@Test
@DisplayName("Ungültige Menge (negativ) → 400")
void reserveStock_negativeQuantity_returns400() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
}
@Test
@DisplayName("Stock nicht gefunden → 404")
void reserveStock_stockNotFound_returns404() throws Exception {
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Ohne STOCK_WRITE → 403")
void reserveStock_withViewerToken_returns403() throws Exception {
String stockId = createStock();
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void reserveStock_withoutToken_returns401() throws Exception {
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Zweite Reservierung reduziert verfügbare Menge weiter → 201")
void reserveStock_multipleReservations_reducesAvailability() throws Exception {
String stockId = createStock();
addBatchToStock(stockId); // 10 KILOGRAM
// Erste Reservierung: 6 kg
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "6", "KILOGRAM", "NORMAL");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req1)))
.andExpect(status().isCreated());
// Zweite Reservierung: 4 kg (genau der Rest)
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "4", "KILOGRAM", "URGENT");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req2)))
.andExpect(status().isCreated());
// Dritte Reservierung: sollte fehlschlagen (nichts mehr verfügbar)
var req3 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "LOW");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req3)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
}
@Test
@DisplayName("Leerer referenceType (Blank) → 400 (Bean Validation)")
void reserveStock_blankReferenceType_returns400() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"referenceType": "", "referenceId": "PO-001", "quantityAmount": "5", "quantityUnit": "KILOGRAM", "priority": "NORMAL"}
"""))
.andExpect(status().isBadRequest());
}
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {