1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49: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
* - 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; }

View file

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

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.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);
}
}

View file

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

View file

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

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.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);

View file

@ -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: