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

feat(inventory): abgelaufene und bald ablaufende Chargen automatisch markieren (CheckStockExpiry)

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.
This commit is contained in:
Sebastian Frick 2026-02-23 13:19:13 +01:00
parent 9eb9c93fb7
commit c4a1e59987
14 changed files with 843 additions and 1 deletions

View file

@ -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<StockError, ExpiryCheckResult> execute(LocalDate referenceDate) {
List<Stock> 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<StockBatchId> marked) -> totalExpired += marked.size();
}
switch (stock.markExpiringSoonBatches(referenceDate)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(List<StockBatchId> 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));
}
}

View file

@ -0,0 +1,3 @@
package de.effigenix.application.inventory;
public record ExpiryCheckResult(int expiredCount, int expiringSoonCount, int failedCount) {}

View file

@ -20,6 +20,8 @@ import java.util.Objects;
* - BatchReference (batchId + batchType) unique within batches * - BatchReference (batchId + batchType) unique within batches
* - blockBatch: AVAILABLE/EXPIRING_SOON BLOCKED; EXPIRED not allowed; already BLOCKED error * - blockBatch: AVAILABLE/EXPIRING_SOON BLOCKED; EXPIRED not allowed; already BLOCKED error
* - 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
* - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired EXPIRING_SOON; requires minimumShelfLife
*/ */
public class Stock { public class Stock {
@ -225,6 +227,41 @@ public class Stock {
return Result.success(null); return Result.success(null);
} }
// ==================== Expiry Management ====================
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
List<StockBatchId> 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<StockError, List<StockBatchId>> markExpiringSoonBatches(LocalDate today) {
if (minimumShelfLife == null) {
return Result.success(List.of());
}
List<StockBatchId> 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 ==================== // ==================== Getters ====================
public StockId id() { return id; } public StockId id() { return id; }

View file

@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -21,5 +22,7 @@ public interface StockRepository {
Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId); Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId);
Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate);
Result<RepositoryError, Void> save(Stock stock); Result<RepositoryError, Void> save(Stock stock);
} }

View file

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

View file

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

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CheckStockExpiry;
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.UpdateStock; import de.effigenix.application.inventory.UpdateStock;
@ -90,4 +91,9 @@ public class InventoryUseCaseConfiguration {
public UnblockStockBatch unblockStockBatch(StockRepository stockRepository, AuditLogger auditLogger) { public UnblockStockBatch unblockStockBatch(StockRepository stockRepository, AuditLogger auditLogger) {
return new UnblockStockBatch(stockRepository, auditLogger); return new UnblockStockBatch(stockRepository, auditLogger);
} }
@Bean
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository);
}
} }

View file

@ -12,6 +12,7 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -103,6 +104,19 @@ public class JpaStockRepository implements StockRepository {
} }
} }
@Override
public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) {
try {
List<Stock> 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 @Override
@Transactional @Transactional
public Result<RepositoryError, Void> save(Stock stock) { public Result<RepositoryError, Void> save(Stock stock) {

View file

@ -2,7 +2,10 @@ package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity; import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
import org.springframework.data.jpa.repository.JpaRepository; 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.List;
import java.util.Optional; import java.util.Optional;
@ -15,4 +18,14 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
List<StockEntity> findAllByStorageLocationId(String storageLocationId); List<StockEntity> findAllByStorageLocationId(String storageLocationId);
List<StockEntity> findAllByArticleId(String articleId); List<StockEntity> 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<StockEntity> findAllWithExpiryRelevantBatches(@Param("today") LocalDate today);
} }

View file

@ -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());
}
}
}
}
}

View file

@ -10,6 +10,7 @@ import de.effigenix.shared.common.Result;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -50,6 +51,11 @@ public class StubStockRepository implements StockRepository {
return Result.failure(STUB_ERROR); return Result.failure(STUB_ERROR);
} }
@Override
public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) {
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

@ -49,6 +49,8 @@ logging:
effigenix: effigenix:
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
inventory:
expiry-check-cron: "0 0 6 * * *"
# API Documentation # API Documentation
springdoc: springdoc:

View file

@ -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<Stock> stocksToReturn = new ArrayList<>();
List<Stock> savedStocks = new ArrayList<>();
boolean failOnFind = false;
boolean failOnSave = false;
void addStock(Stock stock) {
stocksToReturn.add(stock);
}
@Override
public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) {
if (failOnFind) {
return Result.failure(new RepositoryError.DatabaseError("Test DB error"));
}
return Result.success(stocksToReturn);
}
@Override
public Result<RepositoryError, Void> 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<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()); }
}
}

View file

@ -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 ==================== // ==================== Helpers ====================
private Stock createValidStock() { private Stock createValidStock() {
@ -819,11 +1191,15 @@ class StockTest {
} }
private Stock createStockWithBatch(String amount, UnitOfMeasure uom, StockBatchStatus status) { 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( var batch = StockBatch.reconstitute(
StockBatchId.generate(), StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED), new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal(amount), uom), Quantity.reconstitute(new BigDecimal(amount), uom),
LocalDate.of(2026, 12, 31), expiryDate,
status, status,
Instant.now() Instant.now()
); );