From c4a1e59987c5b081a63fafa93b07618b29c0be64 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Mon, 23 Feb 2026 13:19:13 +0100 Subject: [PATCH] feat(inventory): abgelaufene und bald ablaufende Chargen automatisch markieren (CheckStockExpiry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Täglicher Scheduler prüft alle StockBatches und setzt Status auf EXPIRED bzw. EXPIRING_SOON basierend auf MHD und MinimumShelfLife. Reihenfolge: erst EXPIRED, dann EXPIRING_SOON — BLOCKED bleibt unangetastet. --- .../inventory/CheckStockExpiry.java | 58 +++ .../inventory/ExpiryCheckResult.java | 3 + .../de/effigenix/domain/inventory/Stock.java | 37 ++ .../domain/inventory/StockRepository.java | 3 + .../domain/inventory/event/BatchExpired.java | 6 + .../inventory/event/BatchExpiringSoon.java | 6 + .../config/InventoryUseCaseConfiguration.java | 6 + .../repository/JpaStockRepository.java | 14 + .../repository/StockJpaRepository.java | 13 + .../scheduler/StockExpiryScheduler.java | 43 ++ .../stub/StubStockRepository.java | 6 + backend/src/main/resources/application.yml | 2 + .../inventory/CheckStockExpiryTest.java | 269 +++++++++++++ .../effigenix/domain/inventory/StockTest.java | 378 +++++++++++++++++- 14 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/CheckStockExpiry.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ExpiryCheckResult.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpired.java create mode 100644 backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpiringSoon.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/scheduler/StockExpiryScheduler.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/CheckStockExpiry.java b/backend/src/main/java/de/effigenix/application/inventory/CheckStockExpiry.java new file mode 100644 index 0000000..c397cf2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/CheckStockExpiry.java @@ -0,0 +1,58 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.Stock; +import de.effigenix.domain.inventory.StockBatchId; +import de.effigenix.domain.inventory.StockError; +import de.effigenix.domain.inventory.StockRepository; +import de.effigenix.shared.common.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; + +public class CheckStockExpiry { + + private static final Logger logger = LoggerFactory.getLogger(CheckStockExpiry.class); + + private final StockRepository stockRepository; + + public CheckStockExpiry(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(LocalDate referenceDate) { + List stocks; + switch (stockRepository.findAllWithExpiryRelevantBatches(referenceDate)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var val) -> stocks = val; + } + + int totalExpired = 0; + int totalExpiringSoon = 0; + int totalFailed = 0; + + for (Stock stock : stocks) { + switch (stock.markExpiredBatches(referenceDate)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(List marked) -> totalExpired += marked.size(); + } + + switch (stock.markExpiringSoonBatches(referenceDate)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(List marked) -> totalExpiringSoon += marked.size(); + } + + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> { + totalFailed++; + logger.warn("Failed to save stock {} during expiry check: {}", stock.id().value(), err.message()); + } + case Result.Success(var ignored) -> { } + } + } + + return Result.success(new ExpiryCheckResult(totalExpired, totalExpiringSoon, totalFailed)); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/ExpiryCheckResult.java b/backend/src/main/java/de/effigenix/application/inventory/ExpiryCheckResult.java new file mode 100644 index 0000000..a6a85b8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ExpiryCheckResult.java @@ -0,0 +1,3 @@ +package de.effigenix.application.inventory; + +public record ExpiryCheckResult(int expiredCount, int expiringSoonCount, int failedCount) {} 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 3599dfc..147a378 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -20,6 +20,8 @@ import java.util.Objects; * - BatchReference (batchId + batchType) unique within batches * - blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; EXPIRED → not allowed; already 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 + * - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired → EXPIRING_SOON; requires minimumShelfLife */ public class Stock { @@ -225,6 +227,41 @@ public class Stock { return Result.success(null); } + // ==================== Expiry Management ==================== + + public Result> markExpiredBatches(LocalDate today) { + List marked = new ArrayList<>(); + for (int i = 0; i < batches.size(); i++) { + StockBatch batch = batches.get(i); + if ((batch.status() == StockBatchStatus.AVAILABLE || batch.status() == StockBatchStatus.EXPIRING_SOON) + && batch.expiryDate() != null + && batch.expiryDate().isBefore(today)) { + batches.set(i, batch.withStatus(StockBatchStatus.EXPIRED)); + marked.add(batch.id()); + } + } + return Result.success(marked); + } + + public Result> markExpiringSoonBatches(LocalDate today) { + if (minimumShelfLife == null) { + return Result.success(List.of()); + } + List marked = new ArrayList<>(); + LocalDate threshold = today.plusDays(minimumShelfLife.days()); + for (int i = 0; i < batches.size(); i++) { + StockBatch batch = batches.get(i); + if (batch.status() == StockBatchStatus.AVAILABLE + && batch.expiryDate() != null + && batch.expiryDate().isBefore(threshold) + && !batch.expiryDate().isBefore(today)) { + batches.set(i, batch.withStatus(StockBatchStatus.EXPIRING_SOON)); + marked.add(batch.id()); + } + } + return Result.success(marked); + } + // ==================== 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 b025643..8f6a9c4 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockRepository.java @@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -21,5 +22,7 @@ public interface StockRepository { Result> findAllByArticleId(ArticleId articleId); + Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate); + Result save(Stock stock); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpired.java b/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpired.java new file mode 100644 index 0000000..5ed94ad --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpired.java @@ -0,0 +1,6 @@ +package de.effigenix.domain.inventory.event; + +import java.time.LocalDate; + +// TODO: Publish via domain event mechanism when event infrastructure is in place +public record BatchExpired(String stockId, String stockBatchId, String articleId, LocalDate expiryDate) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpiringSoon.java b/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpiringSoon.java new file mode 100644 index 0000000..8933821 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/event/BatchExpiringSoon.java @@ -0,0 +1,6 @@ +package de.effigenix.domain.inventory.event; + +import java.time.LocalDate; + +// TODO: Publish via domain event mechanism when event infrastructure is in place +public record BatchExpiringSoon(String stockId, String stockBatchId, String articleId, LocalDate expiryDate, int daysRemaining) {} 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 540b64a..b17789e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.BlockStockBatch; +import de.effigenix.application.inventory.CheckStockExpiry; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.UpdateStock; @@ -90,4 +91,9 @@ public class InventoryUseCaseConfiguration { public UnblockStockBatch unblockStockBatch(StockRepository stockRepository, AuditLogger auditLogger) { return new UnblockStockBatch(stockRepository, auditLogger); } + + @Bean + public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { + return new CheckStockExpiry(stockRepository); + } } 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 ae14e61..35a37fd 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 @@ -12,6 +12,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -103,6 +104,19 @@ public class JpaStockRepository implements StockRepository { } } + @Override + public Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { + try { + List result = jpaRepository.findAllWithExpiryRelevantBatches(referenceDate).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAllWithExpiryRelevantBatches", 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 a254961..361b9f9 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 @@ -2,7 +2,10 @@ package de.effigenix.infrastructure.inventory.persistence.repository; import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -15,4 +18,14 @@ public interface StockJpaRepository extends JpaRepository { List findAllByStorageLocationId(String storageLocationId); List findAllByArticleId(String articleId); + + @Query(value = """ + SELECT DISTINCT s.* FROM stocks s \ + JOIN stock_batches b ON b.stock_id = s.id \ + WHERE (b.status IN ('AVAILABLE', 'EXPIRING_SOON') AND b.expiry_date < :today) \ + OR (s.minimum_shelf_life_days IS NOT NULL AND b.status = 'AVAILABLE' \ + AND b.expiry_date >= :today \ + AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""", + nativeQuery = true) + List findAllWithExpiryRelevantBatches(@Param("today") LocalDate today); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/scheduler/StockExpiryScheduler.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/scheduler/StockExpiryScheduler.java new file mode 100644 index 0000000..681ede4 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/scheduler/StockExpiryScheduler.java @@ -0,0 +1,43 @@ +package de.effigenix.infrastructure.inventory.scheduler; + +import de.effigenix.application.inventory.CheckStockExpiry; +import de.effigenix.application.inventory.ExpiryCheckResult; +import de.effigenix.shared.common.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@Profile("!no-db") +public class StockExpiryScheduler { + + private static final Logger logger = LoggerFactory.getLogger(StockExpiryScheduler.class); + + private final CheckStockExpiry checkStockExpiry; + + public StockExpiryScheduler(CheckStockExpiry checkStockExpiry) { + this.checkStockExpiry = checkStockExpiry; + } + + @Scheduled(cron = "${effigenix.inventory.expiry-check-cron:0 0 6 * * *}") + public void checkExpiry() { + logger.info("Starting stock expiry check"); + switch (checkStockExpiry.execute(LocalDate.now())) { + case Result.Failure(var err) -> + logger.error("Stock expiry check failed: {} - {}", err.code(), err.message()); + case Result.Success(ExpiryCheckResult result) -> { + if (result.failedCount() > 0) { + logger.warn("Stock expiry check completed with failures: {} expired, {} expiring soon, {} failed", + result.expiredCount(), result.expiringSoonCount(), result.failedCount()); + } else { + logger.info("Stock expiry check completed: {} expired, {} expiring soon", + result.expiredCount(), result.expiringSoonCount()); + } + } + } + } +} 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 3e2b527..83ea97d 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubStockRepository.java @@ -10,6 +10,7 @@ import de.effigenix.shared.common.Result; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -50,6 +51,11 @@ public class StubStockRepository implements StockRepository { return Result.failure(STUB_ERROR); } + @Override + public Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { + return Result.failure(STUB_ERROR); + } + @Override public Result save(Stock stock) { return Result.failure(STUB_ERROR); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7bd1f4f..9bb49a5 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -49,6 +49,8 @@ logging: effigenix: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} + inventory: + expiry-check-cron: "0 0 6 * * *" # API Documentation springdoc: diff --git a/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java new file mode 100644 index 0000000..7035192 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/CheckStockExpiryTest.java @@ -0,0 +1,269 @@ +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 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 CheckStockExpiryTest { + + private InMemoryStockRepository stockRepository; + private CheckStockExpiry useCase; + + @BeforeEach + void setUp() { + stockRepository = new InMemoryStockRepository(); + useCase = new CheckStockExpiry(stockRepository); + } + + @Nested + @DisplayName("execute()") + class Execute { + + @Test + @DisplayName("should mark expired batches and expiring soon batches") + void shouldMarkExpiredAndExpiringSoon() { + var today = LocalDate.of(2026, 6, 15); + + // Stock with minimumShelfLife=30, one expired batch, one expiring soon batch + var expiredBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), // expired + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var expiringSoonBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 1), // 16 days, within 30-day threshold + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), + new ArrayList<>(List.of(expiredBatch, expiringSoonBatch)) + ); + stockRepository.addStock(stock); + + var result = useCase.execute(today); + + assertThat(result.isSuccess()).isTrue(); + var checkResult = result.unsafeGetValue(); + assertThat(checkResult.expiredCount()).isEqualTo(1); + assertThat(checkResult.expiringSoonCount()).isEqualTo(1); + + // Verify saved stock has correct statuses + var savedStock = stockRepository.savedStocks.getFirst(); + assertThat(savedStock.batches().get(0).status()).isEqualTo(StockBatchStatus.EXPIRED); + assertThat(savedStock.batches().get(1).status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + } + + @Test + @DisplayName("should return zero counts when no relevant stocks") + void shouldReturnZeroWhenNoRelevantStocks() { + stockRepository.stocksToReturn = List.of(); + + var result = useCase.execute(LocalDate.of(2026, 6, 15)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().expiredCount()).isZero(); + assertThat(result.unsafeGetValue().expiringSoonCount()).isZero(); + } + + @Test + @DisplayName("should process multiple stocks") + void shouldProcessMultipleStocks() { + var today = LocalDate.of(2026, 6, 15); + + var batch1 = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), StockBatchStatus.AVAILABLE, Instant.now() + ); + var stock1 = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, new ArrayList<>(List.of(batch1)) + ); + + var batch2 = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 12), StockBatchStatus.AVAILABLE, Instant.now() + ); + var stock2 = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-2"), StorageLocationId.of("location-2"), + null, null, new ArrayList<>(List.of(batch2)) + ); + + stockRepository.addStock(stock1); + stockRepository.addStock(stock2); + + var result = useCase.execute(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().expiredCount()).isEqualTo(2); + assertThat(stockRepository.savedStocks).hasSize(2); + } + + @Test + @DisplayName("should only count expired when stock has no minimumShelfLife") + void shouldOnlyCountExpiredWithoutMinimumShelfLife() { + var today = LocalDate.of(2026, 6, 15); + + var expiredBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), StockBatchStatus.AVAILABLE, Instant.now() + ); + var soonBatch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 20), StockBatchStatus.AVAILABLE, Instant.now() + ); + // No minimumShelfLife → expiringSoon should not be marked + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, new ArrayList<>(List.of(expiredBatch, soonBatch)) + ); + stockRepository.addStock(stock); + + var result = useCase.execute(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().expiredCount()).isEqualTo(1); + assertThat(result.unsafeGetValue().expiringSoonCount()).isZero(); + assertThat(stockRepository.savedStocks.getFirst().batches().get(1).status()) + .isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should mark expired before expiring soon to prevent incorrect classification") + void shouldMarkExpiredBeforeExpiringSoon() { + var today = LocalDate.of(2026, 6, 15); + + // Batch that is expired (expiryDate < today) but also within minimumShelfLife threshold + // Must become EXPIRED, not EXPIRING_SOON + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), // expired, but also within 30-day threshold + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + stockRepository.addStock(stock); + + var result = useCase.execute(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().expiredCount()).isEqualTo(1); + assertThat(result.unsafeGetValue().expiringSoonCount()).isZero(); + assertThat(stockRepository.savedStocks.getFirst().batches().getFirst().status()) + .isEqualTo(StockBatchStatus.EXPIRED); + } + + @Test + @DisplayName("should propagate repository failure on load") + void shouldPropagateRepositoryFailureOnLoad() { + stockRepository.failOnFind = true; + + var result = useCase.execute(LocalDate.of(2026, 6, 15)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should continue and count failures when save fails") + void shouldContinueOnSaveFailure() { + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, new ArrayList<>(List.of(batch)) + ); + stockRepository.addStock(stock); + stockRepository.failOnSave = true; + + var result = useCase.execute(LocalDate.of(2026, 6, 15)); + + assertThat(result.isSuccess()).isTrue(); + var checkResult = result.unsafeGetValue(); + assertThat(checkResult.expiredCount()).isEqualTo(1); + assertThat(checkResult.failedCount()).isEqualTo(1); + assertThat(stockRepository.savedStocks).isEmpty(); + } + } + + // ==================== In-Memory Test Double ==================== + + private static class InMemoryStockRepository implements StockRepository { + + List stocksToReturn = new ArrayList<>(); + List savedStocks = new ArrayList<>(); + boolean failOnFind = false; + boolean failOnSave = false; + + void addStock(Stock stock) { + stocksToReturn.add(stock); + } + + @Override + public Result> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { + if (failOnFind) { + return Result.failure(new RepositoryError.DatabaseError("Test DB error")); + } + return Result.success(stocksToReturn); + } + + @Override + public Result save(Stock stock) { + if (failOnSave) { + return Result.failure(new RepositoryError.DatabaseError("Test DB error")); + } + savedStocks.add(stock); + return Result.success(null); + } + + // 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()); } + } +} 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 95e5f44..91dae95 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -811,6 +811,378 @@ class StockTest { } } + // ==================== markExpiredBatches ==================== + + @Nested + @DisplayName("markExpiredBatches()") + class MarkExpiredBatches { + + @Test + @DisplayName("should mark batch with expiryDate before today as EXPIRED") + void shouldMarkExpiredBatch() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 6, 14)); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED); + } + + @Test + @DisplayName("should not mark BLOCKED batch as EXPIRED") + void shouldNotMarkBlockedBatch() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.BLOCKED, LocalDate.of(2026, 6, 14)); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED); + } + + @Test + @DisplayName("should not mark batch with expiryDate equal to today") + void shouldNotMarkBatchExpiringToday() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, today); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should not mark batch with expiryDate after today") + void shouldNotMarkFutureBatch() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should not change already EXPIRED batch") + void shouldNotChangeAlreadyExpiredBatch() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.EXPIRED, LocalDate.of(2026, 6, 10)); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED); + } + + @Test + @DisplayName("should handle multiple batches with mixed statuses") + void shouldHandleMultipleBatchesMixedStatuses() { + var today = LocalDate.of(2026, 6, 15); + var expiredAvailable = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), StockBatchStatus.AVAILABLE, Instant.now() + ); + var expiredExpiringSoon = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 14), StockBatchStatus.EXPIRING_SOON, Instant.now() + ); + var blockedExpired = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-003", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("3"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 1), StockBatchStatus.BLOCKED, Instant.now() + ); + var futureAvailable = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-004", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("7"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(expiredAvailable, expiredExpiringSoon, blockedExpired, futureAvailable)) + ); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(2); + assertThat(stock.batches().get(0).status()).isEqualTo(StockBatchStatus.EXPIRED); + assertThat(stock.batches().get(1).status()).isEqualTo(StockBatchStatus.EXPIRED); + assertThat(stock.batches().get(2).status()).isEqualTo(StockBatchStatus.BLOCKED); + assertThat(stock.batches().get(3).status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should return empty list when no batches exist") + void shouldReturnEmptyWhenNoBatches() { + var today = LocalDate.of(2026, 6, 15); + var stock = createValidStock(); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should mark EXPIRING_SOON batch as EXPIRED when past expiry") + void shouldMarkExpiringSoonAsExpired() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.EXPIRING_SOON, LocalDate.of(2026, 6, 14)); + + var result = stock.markExpiredBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRED); + } + } + + // ==================== markExpiringSoonBatches ==================== + + @Nested + @DisplayName("markExpiringSoonBatches()") + class MarkExpiringSoonBatches { + + @Test + @DisplayName("should mark batch within minimumShelfLife threshold as EXPIRING_SOON") + void shouldMarkExpiringSoon() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 1), // 16 days from today, within 30-day threshold + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + } + + @Test + @DisplayName("should not mark batch outside minimumShelfLife threshold") + void shouldNotMarkBatchOutsideThreshold() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), // well outside 30-day threshold + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should return empty list when no minimumShelfLife configured") + void shouldReturnEmptyWithoutMinimumShelfLife() { + var today = LocalDate.of(2026, 6, 15); + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 6, 20)); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should not mark already EXPIRING_SOON batch again") + void shouldNotDoubleMarkExpiringSoon() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 1), + StockBatchStatus.EXPIRING_SOON, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + } + + @Test + @DisplayName("should not mark batch exactly on threshold boundary (expiryDate == today + minimumShelfLife)") + void shouldNotMarkBatchExactlyOnThreshold() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 15), // exactly today + 30 days + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should mark batch one day before threshold boundary") + void shouldMarkBatchOneDayBeforeThreshold() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 14), // today + 29 days, within 30-day threshold + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + } + + @Test + @DisplayName("should not mark BLOCKED batch within threshold as EXPIRING_SOON") + void shouldNotMarkBlockedBatchWithinThreshold() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 1), // within threshold + StockBatchStatus.BLOCKED, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED); + } + + @Test + @DisplayName("should handle multiple batches with mixed statuses") + void shouldHandleMultipleBatchesMixedStatuses() { + var today = LocalDate.of(2026, 6, 15); + var withinThreshold = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 7, 1), StockBatchStatus.AVAILABLE, Instant.now() + ); + var alreadyExpiringSoon = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-002", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("5"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 20), StockBatchStatus.EXPIRING_SOON, Instant.now() + ); + var outsideThreshold = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-003", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("7"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), + new ArrayList<>(List.of(withinThreshold, alreadyExpiringSoon, outsideThreshold)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(stock.batches().get(0).status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + assertThat(stock.batches().get(1).status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + assertThat(stock.batches().get(2).status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should not mark already expired batch as EXPIRING_SOON") + void shouldNotMarkExpiredAsExpiringSoon() { + var today = LocalDate.of(2026, 6, 15); + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM), + LocalDate.of(2026, 6, 10), // already expired + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), new ArrayList<>(List.of(batch)) + ); + + var result = stock.markExpiringSoonBatches(today); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + } + // ==================== Helpers ==================== private Stock createValidStock() { @@ -819,11 +1191,15 @@ class StockTest { } private Stock createStockWithBatch(String amount, UnitOfMeasure uom, StockBatchStatus status) { + return createStockWithBatchAndExpiry(amount, uom, status, LocalDate.of(2026, 12, 31)); + } + + private Stock createStockWithBatchAndExpiry(String amount, UnitOfMeasure uom, StockBatchStatus status, LocalDate expiryDate) { var batch = StockBatch.reconstitute( StockBatchId.generate(), new BatchReference("BATCH-001", BatchType.PRODUCED), Quantity.reconstitute(new BigDecimal(amount), uom), - LocalDate.of(2026, 12, 31), + expiryDate, status, Instant.now() );