diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListStocksBelowMinimum.java b/backend/src/main/java/de/effigenix/application/inventory/ListStocksBelowMinimum.java new file mode 100644 index 0000000..511fded --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ListStocksBelowMinimum.java @@ -0,0 +1,34 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockError; +import de.effigenix.domain.inventory.StockRepository; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public class ListStocksBelowMinimum { + + private final StockRepository stockRepository; + private final AuthorizationPort authPort; + + public ListStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authPort) { + this.stockRepository = stockRepository; + this.authPort = authPort; + } + + @Transactional(readOnly = true) + public Result> execute(ActorId performedBy) { + if (!authPort.can(performedBy, InventoryAction.STOCK_READ)) { + return Result.failure(new StockError.Unauthorized("Not authorized to list stocks below minimum")); + } + return switch (stockRepository.findAllBelowMinimumLevel()) { + case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message())); + case Result.Success(var val) -> Result.success(val); + }; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index 147a378..f8f9bd8 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -3,12 +3,16 @@ package de.effigenix.domain.inventory; import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * Stock aggregate root. @@ -22,9 +26,13 @@ import java.util.Objects; * - unblockBatch: BLOCKED → AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED → error * - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today → EXPIRED; BLOCKED untouched * - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired → EXPIRING_SOON; requires minimumShelfLife + * - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities + * - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount */ public class Stock { + private static final System.Logger logger = System.getLogger(Stock.class.getName()); + private final StockId id; private final ArticleId articleId; private final StorageLocationId storageLocationId; @@ -262,6 +270,33 @@ public class Stock { return Result.success(marked); } + // ==================== Queries ==================== + + public BigDecimal availableQuantity() { + return batches.stream() + .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) + .map(b -> b.quantity().amount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public boolean isBelowMinimumLevel() { + if (minimumLevel == null) { + return false; + } + Set batchUnits = batches.stream() + .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) + .map(b -> b.quantity().uom()) + .collect(Collectors.toSet()); + UnitOfMeasure minimumUnit = minimumLevel.quantity().uom(); + if (!batchUnits.isEmpty() && !(batchUnits.size() == 1 && batchUnits.contains(minimumUnit))) { + logger.log(System.Logger.Level.WARNING, + "Unit mismatch in stock {0}: batch units {1} vs minimum level unit {2} — skipping below-minimum check", + id, batchUnits, minimumUnit); + return false; + } + return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0; + } + // ==================== Getters ==================== public StockId id() { return id; } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java index 8f6a9c4..c5a8318 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java @@ -24,5 +24,7 @@ public interface StockRepository { Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate); + Result> findAllBelowMinimumLevel(); + Result save(Stock stock); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/event/StockLevelBelowMinimum.java b/backend/src/main/java/de/effigenix/domain/inventory/event/StockLevelBelowMinimum.java new file mode 100644 index 0000000..7f43af3 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/event/StockLevelBelowMinimum.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.inventory.event; + +import java.math.BigDecimal; + +// TODO: Publish via domain event mechanism when event infrastructure is in place +public record StockLevelBelowMinimum( + String stockId, + String articleId, + BigDecimal availableQuantity, + BigDecimal minimumLevelAmount, + String unit +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index b17789e..b36ebc1 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -8,6 +8,7 @@ import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.ListStocks; +import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; @@ -17,6 +18,7 @@ import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.usermanagement.AuditLogger; import de.effigenix.domain.inventory.StockRepository; import de.effigenix.domain.inventory.StorageLocationRepository; +import de.effigenix.shared.security.AuthorizationPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -96,4 +98,9 @@ public class InventoryUseCaseConfiguration { public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { return new CheckStockExpiry(stockRepository); } + + @Bean + public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) { + return new ListStocksBelowMinimum(stockRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java index 35a37fd..66519a0 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockRepository.java @@ -117,6 +117,19 @@ public class JpaStockRepository implements StockRepository { } } + @Override + public Result> findAllBelowMinimumLevel() { + try { + List result = jpaRepository.findAllBelowMinimumLevel().stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAllBelowMinimumLevel", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override @Transactional public Result save(Stock stock) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java index 361b9f9..228d797 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockJpaRepository.java @@ -28,4 +28,13 @@ public interface StockJpaRepository extends JpaRepository { AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""", nativeQuery = true) List findAllWithExpiryRelevantBatches(@Param("today") LocalDate today); + + @Query(value = """ + SELECT DISTINCT s.* FROM stocks s \ + LEFT JOIN stock_batches b ON b.stock_id = s.id AND b.status IN ('AVAILABLE', 'EXPIRING_SOON') \ + WHERE s.minimum_level_amount IS NOT NULL \ + GROUP BY s.id \ + HAVING COALESCE(SUM(b.quantity_amount), 0) < s.minimum_level_amount""", + nativeQuery = true) + List findAllBelowMinimumLevel(); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index 91f26b3..918eda2 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -5,6 +5,7 @@ import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.ListStocks; +import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UpdateStock; @@ -49,18 +50,21 @@ public class StockController { private final UpdateStock updateStock; private final GetStock getStock; private final ListStocks listStocks; + private final ListStocksBelowMinimum listStocksBelowMinimum; private final AddStockBatch addStockBatch; private final RemoveStockBatch removeStockBatch; private final BlockStockBatch blockStockBatch; private final UnblockStockBatch unblockStockBatch; public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, + ListStocksBelowMinimum listStocksBelowMinimum, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { this.createStock = createStock; this.updateStock = updateStock; this.getStock = getStock; this.listStocks = listStocks; + this.listStocksBelowMinimum = listStocksBelowMinimum; this.addStockBatch = addStockBatch; this.removeStockBatch = removeStockBatch; this.blockStockBatch = blockStockBatch; @@ -85,6 +89,22 @@ public class StockController { return ResponseEntity.ok(responses); } + // NOTE: Must be declared before /{id} to avoid Spring matching "below-minimum" as path variable + @GetMapping("/below-minimum") + @PreAuthorize("hasAuthority('STOCK_READ')") + public ResponseEntity> listStocksBelowMinimum(Authentication authentication) { + var result = listStocksBelowMinimum.execute(ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + List responses = result.unsafeGetValue().stream() + .map(StockResponse::from) + .toList(); + return ResponseEntity.ok(responses); + } + @GetMapping("/{id}") @PreAuthorize("hasAuthority('STOCK_READ')") public ResponseEntity getStock(@PathVariable String id) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java index 3ba799a..5bc8151 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/StockResponse.java @@ -1,7 +1,6 @@ package de.effigenix.infrastructure.inventory.web.dto; import de.effigenix.domain.inventory.Stock; -import de.effigenix.domain.inventory.StockBatchStatus; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; @@ -42,10 +41,7 @@ public record StockResponse( .map(b -> b.quantity().amount()) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal availableQuantity = stock.batches().stream() - .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON) - .map(b -> b.quantity().amount()) - .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal availableQuantity = stock.availableQuantity(); String quantityUnit = stock.batches().isEmpty() ? null diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java index 83ea97d..f051e92 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java @@ -56,6 +56,11 @@ public class StubStockRepository implements StockRepository { return Result.failure(STUB_ERROR); } + @Override + public Result> findAllBelowMinimumLevel() { + return Result.failure(STUB_ERROR); + } + @Override public Result save(Stock stock) { return Result.failure(STUB_ERROR); diff --git a/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java index 7035192..e786039 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java @@ -259,6 +259,7 @@ class CheckStockExpiryTest { } // Unused methods for this test + @Override public Result> findAllBelowMinimumLevel() { return Result.success(List.of()); } @Override public Result> findById(StockId id) { return Result.success(Optional.empty()); } @Override public Result> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(Optional.empty()); } @Override public Result existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(false); } diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java new file mode 100644 index 0000000..2340a60 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStocksBelowMinimumTest.java @@ -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 stocksBelowMinimumLevel = new ArrayList<>(); + boolean failOnFind = false; + + @Override + public Result> findAllBelowMinimumLevel() { + if (failOnFind) { + return Result.failure(new RepositoryError.DatabaseError("Test DB error")); + } + return Result.success(stocksBelowMinimumLevel); + } + + // Unused methods for this test + @Override public Result> findById(StockId id) { return Result.success(Optional.empty()); } + @Override public Result> findByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(Optional.empty()); } + @Override public Result existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId) { return Result.success(false); } + @Override public Result> findAll() { return Result.success(List.of()); } + @Override public Result> findAllByStorageLocationId(StorageLocationId storageLocationId) { return Result.success(List.of()); } + @Override public Result> findAllByArticleId(ArticleId articleId) { return Result.success(List.of()); } + @Override public Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { return Result.success(List.of()); } + @Override public Result save(Stock stock) { return Result.success(null); } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index 91dae95..097464b 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -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() { diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index b4f87a7..672f72b 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -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);