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

@ -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<StockError, List<Stock>> 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);
};
}
}

View file

@ -3,12 +3,16 @@ package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/** /**
* Stock aggregate root. * Stock aggregate root.
@ -22,9 +26,13 @@ import java.util.Objects;
* - unblockBatch: BLOCKED AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED error * - unblockBatch: BLOCKED AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED error
* - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today EXPIRED; BLOCKED untouched * - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today EXPIRED; BLOCKED untouched
* - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired EXPIRING_SOON; requires minimumShelfLife * - 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 { public class Stock {
private static final System.Logger logger = System.getLogger(Stock.class.getName());
private final StockId id; private final StockId id;
private final ArticleId articleId; private final ArticleId articleId;
private final StorageLocationId storageLocationId; private final StorageLocationId storageLocationId;
@ -262,6 +270,33 @@ public class Stock {
return Result.success(marked); 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<UnitOfMeasure> 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 ==================== // ==================== Getters ====================
public StockId id() { return id; } public StockId id() { return id; }

View file

@ -24,5 +24,7 @@ public interface StockRepository {
Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate); Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate);
Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel();
Result<RepositoryError, Void> save(Stock stock); Result<RepositoryError, Void> save(Stock stock);
} }

View file

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

View file

@ -8,6 +8,7 @@ import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation; 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.application.usermanagement.AuditLogger;
import de.effigenix.domain.inventory.StockRepository; import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.domain.inventory.StorageLocationRepository; import de.effigenix.domain.inventory.StorageLocationRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -96,4 +98,9 @@ public class InventoryUseCaseConfiguration {
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository); return new CheckStockExpiry(stockRepository);
} }
@Bean
public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) {
return new ListStocksBelowMinimum(stockRepository, authorizationPort);
}
} }

View file

@ -117,6 +117,19 @@ public class JpaStockRepository implements StockRepository {
} }
} }
@Override
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
try {
List<Stock> 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 @Override
@Transactional @Transactional
public Result<RepositoryError, Void> save(Stock stock) { public Result<RepositoryError, Void> save(Stock stock) {

View file

@ -28,4 +28,13 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""", AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""",
nativeQuery = true) nativeQuery = true)
List<StockEntity> findAllWithExpiryRelevantBatches(@Param("today") LocalDate today); List<StockEntity> 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<StockEntity> findAllBelowMinimumLevel();
} }

View file

@ -5,6 +5,7 @@ import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.UpdateStock;
@ -49,18 +50,21 @@ public class StockController {
private final UpdateStock updateStock; private final UpdateStock updateStock;
private final GetStock getStock; private final GetStock getStock;
private final ListStocks listStocks; private final ListStocks listStocks;
private final ListStocksBelowMinimum listStocksBelowMinimum;
private final AddStockBatch addStockBatch; private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch; private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch; private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch; private final UnblockStockBatch unblockStockBatch;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
this.createStock = createStock; this.createStock = createStock;
this.updateStock = updateStock; this.updateStock = updateStock;
this.getStock = getStock; this.getStock = getStock;
this.listStocks = listStocks; this.listStocks = listStocks;
this.listStocksBelowMinimum = listStocksBelowMinimum;
this.addStockBatch = addStockBatch; this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch; this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch; this.blockStockBatch = blockStockBatch;
@ -85,6 +89,22 @@ public class StockController {
return ResponseEntity.ok(responses); 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<List<StockResponse>> listStocksBelowMinimum(Authentication authentication) {
var result = listStocksBelowMinimum.execute(ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
List<StockResponse> responses = result.unsafeGetValue().stream()
.map(StockResponse::from)
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_READ')") @PreAuthorize("hasAuthority('STOCK_READ')")
public ResponseEntity<StockResponse> getStock(@PathVariable String id) { public ResponseEntity<StockResponse> getStock(@PathVariable String id) {

View file

@ -1,7 +1,6 @@
package de.effigenix.infrastructure.inventory.web.dto; package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.Stock; import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockBatchStatus;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -42,10 +41,7 @@ public record StockResponse(
.map(b -> b.quantity().amount()) .map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal availableQuantity = stock.batches().stream() BigDecimal availableQuantity = stock.availableQuantity();
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
.map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
String quantityUnit = stock.batches().isEmpty() String quantityUnit = stock.batches().isEmpty()
? null ? null

View file

@ -56,6 +56,11 @@ public class StubStockRepository implements StockRepository {
return Result.failure(STUB_ERROR); return Result.failure(STUB_ERROR);
} }
@Override
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
return Result.failure(STUB_ERROR);
}
@Override @Override
public Result<RepositoryError, Void> save(Stock stock) { public Result<RepositoryError, Void> save(Stock stock) {
return Result.failure(STUB_ERROR); return Result.failure(STUB_ERROR);

View file

@ -259,6 +259,7 @@ class CheckStockExpiryTest {
} }
// Unused methods for this test // 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>> 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, 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, 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 ==================== // ==================== Helpers ====================
private Stock createValidStock() { 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 ==================== // ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception { private String createStorageLocation() throws Exception {
@ -962,6 +1042,20 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); 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 { private String createStockForArticle(String articleId) throws Exception {
var request = new CreateStockRequest( var request = new CreateStockRequest(
articleId, storageLocationId, null, null, null); articleId, storageLocationId, null, null, null);