mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 11:59:35 +01:00
feat(inventory): Bestand reservieren mit FEFO-Allokation (#12)
Implementiert Story 4.1: Reservierung von Beständen mit automatischer
FEFO-Allokation (First-Expired-First-Out) über verfügbare Chargen.
Domain: Reservation-Entity, StockBatchAllocation, ReservationDraft,
FEFO-Logik in Stock.reserve(), availableQuantity() berücksichtigt
bestehende Allokationen. Neue Error-Varianten für InsufficientStock,
InvalidReferenceType, InvalidReservationPriority, ReservationNotFound.
API: POST /api/inventory/stocks/{stockId}/reservations → 201 Created.
Liquibase: reservations + stock_batch_allocations Tabellen mit FK- und
CHECK-Constraints.
Tests: 43 neue Tests (22 Domain, 10 UseCase, 11 Integration) für
FEFO-Logik, Validierung, Mengenprüfung, Auth und Edge Cases.
This commit is contained in:
parent
b77b209f10
commit
0b49bb2977
38 changed files with 1656 additions and 57 deletions
|
|
@ -41,7 +41,7 @@ class AddStockBatchTest {
|
|||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
validCommand = new AddStockBatchCommand(
|
||||
|
|
@ -123,7 +123,7 @@ class AddStockBatchTest {
|
|||
LocalDate.of(2026, 12, 31),
|
||||
StockBatchStatus.AVAILABLE,
|
||||
Instant.now()
|
||||
))
|
||||
)), List.of()
|
||||
);
|
||||
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class BlockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue");
|
||||
|
|
@ -153,7 +153,7 @@ class BlockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(blockedBatch))
|
||||
new ArrayList<>(List.of(blockedBatch)), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stock)));
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class CheckStockExpiryTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(expiredBatch, expiringSoonBatch))
|
||||
new ArrayList<>(List.of(expiredBatch, expiringSoonBatch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock1 = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch1))
|
||||
null, null, new ArrayList<>(List.of(batch1)), List.of()
|
||||
);
|
||||
|
||||
var batch2 = StockBatch.reconstitute(
|
||||
|
|
@ -113,7 +113,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock2 = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-2"), StorageLocationId.of("location-2"),
|
||||
null, null, new ArrayList<>(List.of(batch2))
|
||||
null, null, new ArrayList<>(List.of(batch2)), List.of()
|
||||
);
|
||||
|
||||
stockRepository.addStock(stock1);
|
||||
|
|
@ -146,7 +146,7 @@ class CheckStockExpiryTest {
|
|||
// No minimumShelfLife → expiringSoon should not be marked
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(expiredBatch, soonBatch))
|
||||
null, null, new ArrayList<>(List.of(expiredBatch, soonBatch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ class CheckStockExpiryTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch))
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
stockRepository.addStock(stock);
|
||||
stockRepository.failOnSave = true;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class DeactivateStorageLocationTest {
|
|||
var locationId = StorageLocationId.of("loc-1");
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.of("stock-1"), ArticleId.of("article-1"),
|
||||
locationId, null, null, List.of()
|
||||
locationId, null, null, List.of(), List.of()
|
||||
);
|
||||
when(storageLocationRepository.findById(locationId))
|
||||
.thenReturn(Result.success(Optional.of(activeLocation("loc-1"))));
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class GetStockTest {
|
|||
StockId.of("stock-1"),
|
||||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class ListStocksBelowMinimumTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal(minimumAmount), UnitOfMeasure.KILOGRAM));
|
||||
return Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-" + availableAmount), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ class ListStocksTest {
|
|||
StockId.of("stock-1"),
|
||||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
stock2 = Stock.reconstitute(
|
||||
StockId.of("stock-2"),
|
||||
ArticleId.of("article-2"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class RemoveStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new RemoveStockBatchCommand("stock-1", "batch-1", "5", "KILOGRAM");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.ReserveStockCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ReserveStock Use Case")
|
||||
class ReserveStockTest {
|
||||
|
||||
@Mock private StockRepository stockRepository;
|
||||
|
||||
private ReserveStock reserveStock;
|
||||
private ReserveStockCommand validCommand;
|
||||
private Stock existingStock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
reserveStock = new ReserveStock(stockRepository);
|
||||
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("50"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31),
|
||||
StockBatchStatus.AVAILABLE,
|
||||
Instant.now()
|
||||
);
|
||||
existingStock = Stock.reconstitute(
|
||||
StockId.of("stock-1"),
|
||||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new ReserveStockCommand(
|
||||
"stock-1", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve stock successfully")
|
||||
void shouldReserveStockSuccessfully() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var reservation = result.unsafeGetValue();
|
||||
assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
|
||||
assertThat(reservation.referenceId()).isEqualTo("PO-001");
|
||||
assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("10"));
|
||||
assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
|
||||
assertThat(reservation.allocations()).isNotEmpty();
|
||||
verify(stockRepository).save(existingStock);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stock does not exist")
|
||||
void shouldFailWhenStockNotFound() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stockId is null")
|
||||
void shouldFailWhenStockIdNull() {
|
||||
var cmd = new ReserveStockCommand(null, "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with StockNotFound when stockId is blank")
|
||||
void shouldFailWhenStockIdBlank() {
|
||||
var cmd = new ReserveStockCommand("", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when findById fails")
|
||||
void shouldFailWhenFindByIdFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with RepositoryFailure when save fails")
|
||||
void shouldFailWhenSaveFails() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
when(stockRepository.save(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = reserveStock.execute(validCommand);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InsufficientStock from domain")
|
||||
void shouldPropagateInsufficientStock() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "999", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidReferenceType from domain")
|
||||
void shouldPropagateInvalidReferenceType() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "INVALID_TYPE", "PO-001", "10", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidReservationPriority from domain")
|
||||
void shouldPropagateInvalidPriority() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "SUPER_HIGH");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should propagate InvalidQuantity from domain")
|
||||
void shouldPropagateInvalidQuantity() {
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(existingStock)));
|
||||
|
||||
var cmd = new ReserveStockCommand("stock-1", "PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL");
|
||||
var result = reserveStock.execute(cmd);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
verify(stockRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ class UnblockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
validCommand = new UnblockStockBatchCommand("stock-1", "batch-1");
|
||||
|
|
@ -153,7 +153,7 @@ class UnblockStockBatchTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(availableBatch))
|
||||
new ArrayList<>(List.of(availableBatch)), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stock)));
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class UpdateStockTest {
|
|||
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
List.of()
|
||||
List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ class UpdateStockTest {
|
|||
StorageLocationId.of("location-1"),
|
||||
MinimumLevel.of("25", "KILOGRAM").unsafeGetValue(),
|
||||
MinimumShelfLife.of(7).unsafeGetValue(),
|
||||
List.of()
|
||||
List.of(), List.of()
|
||||
);
|
||||
when(stockRepository.findById(StockId.of("stock-1")))
|
||||
.thenReturn(Result.success(Optional.of(stockWithParams)));
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(quantity);
|
||||
var minimumShelfLife = new MinimumShelfLife(30);
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of());
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, minimumLevel, minimumShelfLife, List.of(), List.of());
|
||||
|
||||
assertThat(stock.id()).isEqualTo(id);
|
||||
assertThat(stock.articleId()).isEqualTo(articleId);
|
||||
|
|
@ -503,7 +503,7 @@ class StockTest {
|
|||
var articleId = ArticleId.of("article-1");
|
||||
var locationId = StorageLocationId.of("location-1");
|
||||
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of());
|
||||
var stock = Stock.reconstitute(id, articleId, locationId, null, null, List.of(), List.of());
|
||||
|
||||
assertThat(stock.minimumLevel()).isNull();
|
||||
assertThat(stock.minimumShelfLife()).isNull();
|
||||
|
|
@ -520,8 +520,8 @@ class StockTest {
|
|||
@DisplayName("should be equal if same ID")
|
||||
void shouldBeEqualBySameId() {
|
||||
var id = StockId.generate();
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of());
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of());
|
||||
var stock1 = Stock.reconstitute(id, ArticleId.of("a1"), StorageLocationId.of("l1"), null, null, List.of(), List.of());
|
||||
var stock2 = Stock.reconstitute(id, ArticleId.of("a2"), StorageLocationId.of("l2"), null, null, List.of(), List.of());
|
||||
|
||||
assertThat(stock1).isEqualTo(stock2);
|
||||
assertThat(stock1.hashCode()).isEqualTo(stock2.hashCode());
|
||||
|
|
@ -751,7 +751,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
|
|
@ -777,7 +777,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
|
|
@ -918,7 +918,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable))
|
||||
new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiredBatches(today);
|
||||
|
|
@ -978,7 +978,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1002,7 +1002,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1040,7 +1040,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1064,7 +1064,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1088,7 +1088,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1112,7 +1112,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1147,7 +1147,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30),
|
||||
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold))
|
||||
new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1173,7 +1173,7 @@ class StockTest {
|
|||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch))
|
||||
null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.markExpiringSoonBatches(today);
|
||||
|
|
@ -1219,7 +1219,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(available, expiringSoon, blocked, expired))
|
||||
new ArrayList<>(List.of(available, expiringSoon, blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
|
||||
|
|
@ -1250,7 +1250,7 @@ class StockTest {
|
|||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(blocked, expired))
|
||||
new ArrayList<>(List.of(blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
|
|
@ -1268,7 +1268,7 @@ class StockTest {
|
|||
void shouldReturnFalseWithoutMinimumLevel() {
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, List.of()
|
||||
null, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1286,7 +1286,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1304,7 +1304,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1322,7 +1322,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1334,7 +1334,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, List.of()
|
||||
minimumLevel, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1346,7 +1346,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, List.of()
|
||||
minimumLevel, null, List.of(), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
|
|
@ -1370,7 +1370,7 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(blocked, expired))
|
||||
minimumLevel, null, new ArrayList<>(List.of(blocked, expired)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isTrue();
|
||||
|
|
@ -1394,13 +1394,400 @@ class StockTest {
|
|||
var minimumLevel = new MinimumLevel(Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM));
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch))
|
||||
minimumLevel, null, new ArrayList<>(List.of(kgBatch, literBatch)), List.of()
|
||||
);
|
||||
|
||||
assertThat(stock.isBelowMinimumLevel()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== reserve (FEFO) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("reserve()")
|
||||
class Reserve {
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve from single batch")
|
||||
void shouldReserveFromSingleBatch() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
var batchId = stock.batches().getFirst().id();
|
||||
|
||||
var draft = new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
var result = stock.reserve(draft);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var reservation = result.unsafeGetValue();
|
||||
assertThat(reservation.id()).isNotNull();
|
||||
assertThat(reservation.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
|
||||
assertThat(reservation.referenceId()).isEqualTo("PO-001");
|
||||
assertThat(reservation.quantity().amount()).isEqualByComparingTo(new BigDecimal("5"));
|
||||
assertThat(reservation.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||
assertThat(reservation.priority()).isEqualTo(ReservationPriority.NORMAL);
|
||||
assertThat(reservation.reservedAt()).isNotNull();
|
||||
assertThat(reservation.allocations()).hasSize(1);
|
||||
assertThat(reservation.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
|
||||
assertThat(reservation.allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should allocate FEFO – earliest expiry first")
|
||||
void shouldAllocateFefoEarliestFirst() {
|
||||
var earlyBatch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-EARLY", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var lateBatch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-LATE", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(lateBatch, earlyBatch)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var allocs = result.unsafeGetValue().allocations();
|
||||
assertThat(allocs).hasSize(1);
|
||||
assertThat(allocs.getFirst().stockBatchId()).isEqualTo(earlyBatch.id());
|
||||
assertThat(allocs.getFirst().allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should split allocation across multiple batches (FEFO)")
|
||||
void shouldSplitAcrossMultipleBatches() {
|
||||
var batch1 = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var batch2 = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-002", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 9, 30), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var allocs = result.unsafeGetValue().allocations();
|
||||
assertThat(allocs).hasSize(2);
|
||||
assertThat(allocs.get(0).stockBatchId()).isEqualTo(batch1.id());
|
||||
assertThat(allocs.get(0).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("6"));
|
||||
assertThat(allocs.get(1).stockBatchId()).isEqualTo(batch2.id());
|
||||
assertThat(allocs.get(1).allocatedQuantity().amount()).isEqualByComparingTo(new BigDecimal("4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should include EXPIRING_SOON batches in allocation")
|
||||
void shouldIncludeExpiringSoonBatches() {
|
||||
var expiringSoon = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(expiringSoon)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
|
||||
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.URGENT);
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should skip BLOCKED and EXPIRED batches")
|
||||
void shouldSkipBlockedAndExpiredBatches() {
|
||||
var blocked = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-BLOCKED", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
|
||||
);
|
||||
var expired = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-EXPIRED", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("100"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
|
||||
);
|
||||
var available = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-AVAIL", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(blocked, expired, available)), List.of()
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().stockBatchId()).isEqualTo(available.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when not enough available")
|
||||
void shouldFailWhenInsufficientStock() {
|
||||
var stock = createStockWithBatchAndExpiry("5", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when all stock is already reserved")
|
||||
void shouldFailWhenAllStockAlreadyReserved() {
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var existingReservation = new Reservation(
|
||||
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
|
||||
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
|
||||
ReservationPriority.NORMAL, Instant.now(),
|
||||
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM)))
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should consider existing reservations when allocating")
|
||||
void shouldConsiderExistingReservations() {
|
||||
var batch = StockBatch.reconstitute(
|
||||
StockBatchId.generate(),
|
||||
new BatchReference("BATCH-001", BatchType.PRODUCED),
|
||||
Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
|
||||
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
|
||||
);
|
||||
var existingReservation = new Reservation(
|
||||
ReservationId.generate(), ReferenceType.PRODUCTION_ORDER, "PO-OLD",
|
||||
Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
|
||||
ReservationPriority.NORMAL, Instant.now(),
|
||||
List.of(new StockBatchAllocation(AllocationId.generate(), batch.id(), Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM)))
|
||||
);
|
||||
var stock = Stock.reconstitute(
|
||||
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
|
||||
null, null, new ArrayList<>(List.of(batch)), List.of(existingReservation)
|
||||
);
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-002", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should update availableQuantity after reservation")
|
||||
void shouldUpdateAvailableQuantityAfterReservation() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20"));
|
||||
|
||||
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should add reservation to reservations list")
|
||||
void shouldAddReservationToList() {
|
||||
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
assertThat(stock.reservations()).isEmpty();
|
||||
|
||||
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT"));
|
||||
|
||||
assertThat(stock.reservations()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InsufficientStock when stock has no batches")
|
||||
void shouldFailWhenNoBatches() {
|
||||
var stock = createValidStock();
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "1", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InsufficientStock.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should reserve exact full amount of batch")
|
||||
void shouldReserveExactFullAmount() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().allocations()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().allocations().getFirst().allocatedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("10"));
|
||||
}
|
||||
|
||||
// ==================== Validation Edge Cases ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceType when null")
|
||||
void shouldFailWhenReferenceTypeNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft(null, "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceType when invalid value")
|
||||
void shouldFailWhenReferenceTypeInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceId when referenceId is blank")
|
||||
void shouldFailWhenReferenceIdBlank() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "", "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReferenceId when referenceId is null")
|
||||
void shouldFailWhenReferenceIdNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", null, "5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReferenceId.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when amount is not a number")
|
||||
void shouldFailWhenQuantityNotNumber() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "abc", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when amount is negative")
|
||||
void shouldFailWhenQuantityNegative() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidQuantity when unit is invalid")
|
||||
void shouldFailWhenUnitInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "INVALID_UNIT", "NORMAL"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReservationPriority when null")
|
||||
void shouldFailWhenPriorityNull() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", null));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidReservationPriority when invalid value")
|
||||
void shouldFailWhenPriorityInvalid() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT"));
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidReservationPriority.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should accept LOW priority")
|
||||
void shouldAcceptLowPriority() {
|
||||
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
|
||||
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
|
||||
|
||||
var result = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "LOW"));
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().priority()).isEqualTo(ReservationPriority.LOW);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private Stock createValidStock() {
|
||||
|
|
@ -1426,7 +1813,7 @@ class StockTest {
|
|||
ArticleId.of("article-1"),
|
||||
StorageLocationId.of("location-1"),
|
||||
null, null,
|
||||
new ArrayList<>(List.of(batch))
|
||||
new ArrayList<>(List.of(batch)), List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import de.effigenix.domain.usermanagement.RoleName;
|
|||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.ReserveStockRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -982,6 +983,220 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Bestand reservieren (FEFO) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /{stockId}/reservations – Bestand reservieren")
|
||||
class ReserveStockEndpoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Reservierung mit gültigen Daten → 201")
|
||||
void reserveStock_valid_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.referenceType").value("PRODUCTION_ORDER"))
|
||||
.andExpect(jsonPath("$.referenceId").value("PO-001"))
|
||||
.andExpect(jsonPath("$.quantityAmount").value(5))
|
||||
.andExpect(jsonPath("$.quantityUnit").value("KILOGRAM"))
|
||||
.andExpect(jsonPath("$.priority").value("NORMAL"))
|
||||
.andExpect(jsonPath("$.reservedAt").isNotEmpty())
|
||||
.andExpect(jsonPath("$.allocations").isArray())
|
||||
.andExpect(jsonPath("$.allocations.length()").value(1))
|
||||
.andExpect(jsonPath("$.allocations[0].stockBatchId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.allocations[0].allocatedQuantityAmount").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SALE_ORDER mit URGENT-Priorität → 201")
|
||||
void reserveStock_saleOrderUrgent_returns201() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.referenceType").value("SALE_ORDER"))
|
||||
.andExpect(jsonPath("$.priority").value("URGENT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reservierung sichtbar bei GET Stock → 200")
|
||||
void reserveStock_visibleOnGet() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reservations").isArray())
|
||||
.andExpect(jsonPath("$.reservations.length()").value(1))
|
||||
.andExpect(jsonPath("$.reservations[0].referenceId").value("PO-001"))
|
||||
.andExpect(jsonPath("$.availableQuantity").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungenügender Bestand → 409")
|
||||
void reserveStock_insufficientStock_returns409() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId); // 10 KILOGRAM
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "999", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültiger ReferenceType → 400")
|
||||
void reserveStock_invalidReferenceType_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("INVALID_TYPE", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_REFERENCE_TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Priorität → 400")
|
||||
void reserveStock_invalidPriority_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "SUPER_URGENT");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_RESERVATION_PRIORITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Menge (negativ) → 400")
|
||||
void reserveStock_negativeQuantity_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "-5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Stock nicht gefunden → 404")
|
||||
void reserveStock_stockNotFound_returns404() throws Exception {
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne STOCK_WRITE → 403")
|
||||
void reserveStock_withViewerToken_returns403() throws Exception {
|
||||
String stockId = createStock();
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne Token → 401")
|
||||
void reserveStock_withoutToken_returns401() throws Exception {
|
||||
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", UUID.randomUUID().toString())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zweite Reservierung reduziert verfügbare Menge weiter → 201")
|
||||
void reserveStock_multipleReservations_reducesAvailability() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId); // 10 KILOGRAM
|
||||
|
||||
// Erste Reservierung: 6 kg
|
||||
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "6", "KILOGRAM", "NORMAL");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req1)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Zweite Reservierung: 4 kg (genau der Rest)
|
||||
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "4", "KILOGRAM", "URGENT");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req2)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Dritte Reservierung: sollte fehlschlagen (nichts mehr verfügbar)
|
||||
var req3 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-002", "1", "KILOGRAM", "LOW");
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req3)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Leerer referenceType (Blank) → 400 (Bean Validation)")
|
||||
void reserveStock_blankReferenceType_returns400() throws Exception {
|
||||
String stockId = createStock();
|
||||
addBatchToStock(stockId);
|
||||
|
||||
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"referenceType": "", "referenceId": "PO-001", "quantityAmount": "5", "quantityUnit": "KILOGRAM", "priority": "NORMAL"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Hilfsmethoden ====================
|
||||
|
||||
private String createStorageLocation() throws Exception {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue