mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:49:36 +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:
parent
3c660650e5
commit
b2b3b59ce9
14 changed files with 602 additions and 5 deletions
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 ====================
|
||||
|
||||
public StockId id() { return id; }
|
||||
|
|
|
|||
|
|
@ -24,5 +24,7 @@ public interface StockRepository {
|
|||
|
||||
Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate);
|
||||
|
||||
Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel();
|
||||
|
||||
Result<RepositoryError, Void> save(Stock stock);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
|
|
|
|||
|
|
@ -28,4 +28,13 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
|
|||
AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""",
|
||||
nativeQuery = true)
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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}")
|
||||
@PreAuthorize("hasAuthority('STOCK_READ')")
|
||||
public ResponseEntity<StockResponse> getStock(@PathVariable String id) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ public class StubStockRepository implements StockRepository {
|
|||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue