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

feat(inventory): Bestände unter Mindestbestand ermitteln (ListStocksBelowMinimum)

GET /api/inventory/stocks/below-minimum zeigt Bestände, deren verfügbare
Menge (AVAILABLE + EXPIRING_SOON) unter dem konfigurierten Mindestbestand
liegt. DB-Prefilter via findAllWithMinimumLevel() + Domain-Filter als
Defense in Depth. StockResponse.from() nutzt nun Stock.availableQuantity()
statt duplizierter Logik.

Closes #11
This commit is contained in:
Sebastian Frick 2026-02-23 14:07:57 +01:00
parent 3c660650e5
commit b2b3b59ce9
14 changed files with 602 additions and 5 deletions

View file

@ -0,0 +1,34 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
public class ListStocksBelowMinimum {
private final StockRepository stockRepository;
private final AuthorizationPort authPort;
public ListStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authPort) {
this.stockRepository = stockRepository;
this.authPort = authPort;
}
@Transactional(readOnly = true)
public Result<StockError, List<Stock>> execute(ActorId performedBy) {
if (!authPort.can(performedBy, InventoryAction.STOCK_READ)) {
return Result.failure(new StockError.Unauthorized("Not authorized to list stocks below minimum"));
}
return switch (stockRepository.findAllBelowMinimumLevel()) {
case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message()));
case Result.Success(var val) -> Result.success(val);
};
}
}

View file

@ -3,12 +3,16 @@ package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Stock aggregate root.
@ -22,9 +26,13 @@ import java.util.Objects;
* - 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
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
*/
public class Stock {
private static final System.Logger logger = System.getLogger(Stock.class.getName());
private final StockId id;
private final ArticleId articleId;
private final StorageLocationId storageLocationId;
@ -262,6 +270,33 @@ public class Stock {
return Result.success(marked);
}
// ==================== Queries ====================
public BigDecimal availableQuantity() {
return batches.stream()
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
.map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public boolean isBelowMinimumLevel() {
if (minimumLevel == null) {
return false;
}
Set<UnitOfMeasure> batchUnits = batches.stream()
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
.map(b -> b.quantity().uom())
.collect(Collectors.toSet());
UnitOfMeasure minimumUnit = minimumLevel.quantity().uom();
if (!batchUnits.isEmpty() && !(batchUnits.size() == 1 && batchUnits.contains(minimumUnit))) {
logger.log(System.Logger.Level.WARNING,
"Unit mismatch in stock {0}: batch units {1} vs minimum level unit {2} — skipping below-minimum check",
id, batchUnits, minimumUnit);
return false;
}
return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0;
}
// ==================== Getters ====================
public StockId id() { return id; }

View file

@ -24,5 +24,7 @@ public interface StockRepository {
Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate);
Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel();
Result<RepositoryError, Void> save(Stock stock);
}

View file

@ -0,0 +1,12 @@
package de.effigenix.domain.inventory.event;
import java.math.BigDecimal;
// TODO: Publish via domain event mechanism when event infrastructure is in place
public record StockLevelBelowMinimum(
String stockId,
String articleId,
BigDecimal availableQuantity,
BigDecimal minimumLevelAmount,
String unit
) {}

View file

@ -8,6 +8,7 @@ import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation;
@ -17,6 +18,7 @@ import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.application.usermanagement.AuditLogger;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.domain.inventory.StorageLocationRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -96,4 +98,9 @@ public class InventoryUseCaseConfiguration {
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository);
}
@Bean
public ListStocksBelowMinimum listStocksBelowMinimum(StockRepository stockRepository, AuthorizationPort authorizationPort) {
return new ListStocksBelowMinimum(stockRepository, authorizationPort);
}
}

View file

@ -117,6 +117,19 @@ public class JpaStockRepository implements StockRepository {
}
}
@Override
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
try {
List<Stock> result = jpaRepository.findAllBelowMinimumLevel().stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAllBelowMinimumLevel", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(Stock stock) {

View file

@ -28,4 +28,13 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
AND b.expiry_date < :today + s.minimum_shelf_life_days * INTERVAL '1 day')""",
nativeQuery = true)
List<StockEntity> findAllWithExpiryRelevantBatches(@Param("today") LocalDate today);
@Query(value = """
SELECT DISTINCT s.* FROM stocks s \
LEFT JOIN stock_batches b ON b.stock_id = s.id AND b.status IN ('AVAILABLE', 'EXPIRING_SOON') \
WHERE s.minimum_level_amount IS NOT NULL \
GROUP BY s.id \
HAVING COALESCE(SUM(b.quantity_amount), 0) < s.minimum_level_amount""",
nativeQuery = true)
List<StockEntity> findAllBelowMinimumLevel();
}

View file

@ -5,6 +5,7 @@ import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.UpdateStock;
@ -49,18 +50,21 @@ public class StockController {
private final UpdateStock updateStock;
private final GetStock getStock;
private final ListStocks listStocks;
private final ListStocksBelowMinimum listStocksBelowMinimum;
private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
this.createStock = createStock;
this.updateStock = updateStock;
this.getStock = getStock;
this.listStocks = listStocks;
this.listStocksBelowMinimum = listStocksBelowMinimum;
this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch;
@ -85,6 +89,22 @@ public class StockController {
return ResponseEntity.ok(responses);
}
// NOTE: Must be declared before /{id} to avoid Spring matching "below-minimum" as path variable
@GetMapping("/below-minimum")
@PreAuthorize("hasAuthority('STOCK_READ')")
public ResponseEntity<List<StockResponse>> listStocksBelowMinimum(Authentication authentication) {
var result = listStocksBelowMinimum.execute(ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
List<StockResponse> responses = result.unsafeGetValue().stream()
.map(StockResponse::from)
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('STOCK_READ')")
public ResponseEntity<StockResponse> getStock(@PathVariable String id) {

View file

@ -1,7 +1,6 @@
package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockBatchStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
@ -42,10 +41,7 @@ public record StockResponse(
.map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal availableQuantity = stock.batches().stream()
.filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
.map(b -> b.quantity().amount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal availableQuantity = stock.availableQuantity();
String quantityUnit = stock.batches().isEmpty()
? null

View file

@ -56,6 +56,11 @@ public class StubStockRepository implements StockRepository {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Stock>> findAllBelowMinimumLevel() {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(Stock stock) {
return Result.failure(STUB_ERROR);