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

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