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

feat(inventory): Bestände unter Mindestbestand ermitteln (ListStocksBelowMinimum)

GET /api/inventory/stocks/below-minimum zeigt Bestände, deren verfügbare
Menge (AVAILABLE + EXPIRING_SOON) unter dem konfigurierten Mindestbestand
liegt. DB-Prefilter via findAllWithMinimumLevel() + Domain-Filter als
Defense in Depth. StockResponse.from() nutzt nun Stock.availableQuantity()
statt duplizierter Logik.

Closes #11
This commit is contained in:
Sebastian Frick 2026-02-23 14:07:57 +01:00
parent 3c660650e5
commit b2b3b59ce9
14 changed files with 602 additions and 5 deletions

View file

@ -259,6 +259,7 @@ class CheckStockExpiryTest {
}
// Unused methods for this test
@Override public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() { return Result.success(List.of()); }
@Override public Result<RepositoryError, Optional<Stock>> findById(StockId id) { return Result.success(Optional.empty()); }
@Override public Result<RepositoryError, Optional<Stock>> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(Optional.empty()); }
@Override public Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(false); }

View file

@ -0,0 +1,151 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.ArticleId;
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 de.effigenix.shared.security.Action;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.security.ResourceId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
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;
class ListStocksBelowMinimumTest {
private static final ActorId ACTOR = ActorId.of("test-user");
private InMemoryStockRepository stockRepository;
private AllowAllAuthorizationPort authPort;
private ListStocksBelowMinimum useCase;
@BeforeEach
void setUp() {
stockRepository = new InMemoryStockRepository();
authPort = new AllowAllAuthorizationPort();
useCase = new ListStocksBelowMinimum(stockRepository, authPort);
}
@Nested
@DisplayName("execute()")
class Execute {
@Test
@DisplayName("should return stocks from repository directly")
void shouldReturnStocksFromRepository() {
var stock1 = createStockWithMinimumLevel("5", "10");
var stock2 = createStockWithMinimumLevel("3", "10");
stockRepository.stocksBelowMinimumLevel = List.of(stock1, stock2);
var result = useCase.execute(ACTOR);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
assertThat(result.unsafeGetValue()).containsExactly(stock1, stock2);
}
@Test
@DisplayName("should return empty list when repository returns empty")
void shouldReturnEmptyWhenRepositoryReturnsEmpty() {
stockRepository.stocksBelowMinimumLevel = List.of();
var result = useCase.execute(ACTOR);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should propagate repository failure")
void shouldPropagateRepositoryFailure() {
stockRepository.failOnFind = true;
var result = useCase.execute(ACTOR);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should return Unauthorized when actor lacks permission")
void shouldReturnUnauthorizedWhenNotPermitted() {
authPort.allowed = false;
var result = useCase.execute(ACTOR);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.Unauthorized.class);
}
}
// ==================== Helpers ====================
private Stock createStockWithMinimumLevel(String availableAmount, String minimumAmount) {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-" + availableAmount, BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal(availableAmount), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
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))
);
}
// ==================== Authorization Test Double ====================
private static class AllowAllAuthorizationPort implements AuthorizationPort {
boolean allowed = true;
@Override
public boolean can(ActorId actor, Action action) {
return allowed;
}
@Override
public boolean can(ActorId actor, Action action, ResourceId resource) {
return allowed;
}
}
// ==================== In-Memory Test Double ====================
private static class InMemoryStockRepository implements StockRepository {
List<Stock> stocksBelowMinimumLevel = new ArrayList<>();
boolean failOnFind = false;
@Override
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
if (failOnFind) {
return Result.failure(new RepositoryError.DatabaseError("Test DB error"));
}
return Result.success(stocksBelowMinimumLevel);
}
// Unused methods for this test
@Override public Result<RepositoryError, Optional<Stock>> findById(StockId id) { return Result.success(Optional.empty()); }
@Override public Result<RepositoryError, Optional<Stock>> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(Optional.empty()); }
@Override public Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(false); }
@Override public Result<RepositoryError, List<Stock>> findAll() { return Result.success(List.of()); }
@Override public Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId) { return Result.success(List.of()); }
@Override public Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId) { return Result.success(List.of()); }
@Override public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { return Result.success(List.of()); }
@Override public Result<RepositoryError, Void> save(Stock stock) { return Result.success(null); }
}
}

View file

@ -1183,6 +1183,224 @@ class StockTest {
}
}
// ==================== availableQuantity ====================
@Nested
@DisplayName("availableQuantity()")
class AvailableQuantity {
@Test
@DisplayName("should sum AVAILABLE and EXPIRING_SOON batch quantities")
void shouldSumAvailableAndExpiringSoon() {
var available = 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 expiringSoon = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 7, 1), StockBatchStatus.EXPIRING_SOON, Instant.now()
);
var blocked = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-003", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
);
var expired = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-004", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("2"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(available, expiringSoon, blocked, expired))
);
assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("15"));
}
@Test
@DisplayName("should return zero when no batches")
void shouldReturnZeroWhenNoBatches() {
var stock = createValidStock();
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("should return zero when only BLOCKED and EXPIRED batches")
void shouldReturnZeroWhenOnlyBlockedAndExpired() {
var blocked = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
);
var expired = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null,
new ArrayList<>(List.of(blocked, expired))
);
assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO);
}
}
// ==================== isBelowMinimumLevel ====================
@Nested
@DisplayName("isBelowMinimumLevel()")
class IsBelowMinimumLevel {
@Test
@DisplayName("should return false when no minimumLevel configured")
void shouldReturnFalseWithoutMinimumLevel() {
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, List.of()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
@Test
@DisplayName("should return false when available > minimum")
void shouldReturnFalseWhenAboveMinimum() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("15"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
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))
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
@Test
@DisplayName("should return false when available == minimum")
void shouldReturnFalseWhenEqualToMinimum() {
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 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))
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
@Test
@DisplayName("should return true when available < minimum")
void shouldReturnTrueWhenBelowMinimum() {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
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))
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
}
@Test
@DisplayName("should return true when no batches and minimumLevel > 0")
void shouldReturnTrueWhenNoBatchesAndMinimumLevelPositive() {
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()
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
}
@Test
@DisplayName("should return false when minimumLevel == 0")
void shouldReturnFalseWhenMinimumLevelZero() {
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()
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
@Test
@DisplayName("should return true when only BLOCKED and EXPIRED batches with minimumLevel > 0")
void shouldReturnTrueWhenOnlyBlockedAndExpiredBatches() {
var blocked = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.BLOCKED, Instant.now()
);
var expired = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 1, 1), StockBatchStatus.EXPIRED, Instant.now()
);
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))
);
assertThat(stock.isBelowMinimumLevel()).isTrue();
}
@Test
@DisplayName("should return false when batches have mixed units different from minimumLevel unit")
void shouldReturnFalseWhenMixedUnits() {
var kgBatch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var literBatch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("2"), UnitOfMeasure.LITER),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
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))
);
assertThat(stock.isBelowMinimumLevel()).isFalse();
}
}
// ==================== Helpers ====================
private Stock createValidStock() {

View file

@ -902,6 +902,86 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
}
}
// ==================== Bestände unter Mindestbestand (belowMinimum) ====================
@Nested
@DisplayName("GET /below-minimum Bestände unter Mindestbestand")
class BelowMinimum {
@Test
@DisplayName("Stock unter Mindestbestand → wird zurückgegeben")
void belowMinimum_returnsStockBelowMinimum() throws Exception {
// Stock mit minimumLevel=100 KILOGRAM und einer Charge von 10 KILOGRAM unter Minimum
String stockId = createStockWithMinimumLevel("100", "KILOGRAM");
addBatchToStock(stockId); // adds 10 KILOGRAM
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(stockId));
}
@Test
@DisplayName("Stock auf Mindestbestand → wird nicht zurückgegeben")
void belowMinimum_excludesStockAtMinimum() throws Exception {
// Stock mit minimumLevel=10 KILOGRAM und einer Charge von 10 KILOGRAM auf Minimum
String stockId = createStockWithMinimumLevel("10", "KILOGRAM");
addBatchToStock(stockId); // adds 10 KILOGRAM
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Stock ohne Chargen aber mit MinimumLevel > 0 → wird zurückgegeben")
void belowMinimum_noBatchesWithMinimumLevel() throws Exception {
createStockWithMinimumLevel("10", "KILOGRAM");
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("Stock ohne MinimumLevel → wird nicht zurückgegeben")
void belowMinimum_excludesStockWithoutMinimumLevel() throws Exception {
createStock();
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Leere Liste wenn keine Stocks vorhanden → 200")
void belowMinimum_empty_returns200() throws Exception {
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
@DisplayName("Ohne STOCK_READ → 403")
void belowMinimum_withViewerToken_returns403() throws Exception {
mockMvc.perform(get("/api/inventory/stocks/below-minimum")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void belowMinimum_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/inventory/stocks/below-minimum"))
.andExpect(status().isUnauthorized());
}
}
// ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception {
@ -962,6 +1042,20 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createStockWithMinimumLevel(String minimumAmount, String unit) throws Exception {
var request = new CreateStockRequest(
UUID.randomUUID().toString(), storageLocationId, minimumAmount, unit, null);
var result = mockMvc.perform(post("/api/inventory/stocks")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
private String createStockForArticle(String articleId) throws Exception {
var request = new CreateStockRequest(
articleId, storageLocationId, null, null, null);