mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:00:23 +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:
parent
9eb9c93fb7
commit
c4a1e59987
14 changed files with 843 additions and 1 deletions
|
|
@ -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()); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue