mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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,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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
public record ExpiryCheckResult(int expiredCount, int expiringSoonCount, int failedCount) {}
|
||||
|
|
@ -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<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 ====================
|
||||
|
||||
public StockId id() { return id; }
|
||||
|
|
|
|||
|
|
@ -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<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId);
|
||||
|
||||
Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate);
|
||||
|
||||
Result<RepositoryError, Void> save(Stock stock);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
@Transactional
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
|
|
|
|||
|
|
@ -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<StockEntity, String> {
|
|||
List<StockEntity> findAllByStorageLocationId(String storageLocationId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Void> save(Stock stock) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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