1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00

feat(inventory): GET-Endpoints für Bestandsposition und Chargen abfragen

Query-Endpoints für Stock-Aggregate: GET /api/inventory/stocks (mit
optionalen Filtern storageLocationId/articleId) und GET /api/inventory/stocks/{id}.
StockResponse enthält nun Batches, totalQuantity, availableQuantity und
quantityUnit. Abgesichert über @PreAuthorize STOCK_READ.
This commit is contained in:
Sebastian Frick 2026-02-20 08:45:20 +01:00
parent e0ac2c2f41
commit 1ef37497c3
12 changed files with 685 additions and 4 deletions

View file

@ -0,0 +1,31 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.inventory.StockId;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.shared.common.Result;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public class GetStock {
private final StockRepository stockRepository;
public GetStock(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, Stock> execute(String stockId) {
if (stockId == null || stockId.isBlank()) {
return Result.failure(new StockError.StockNotFound(stockId));
}
return switch (stockRepository.findById(StockId.of(stockId))) {
case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message()));
case Result.Success(var opt) -> opt
.<Result<StockError, Stock>>map(Result::success)
.orElseGet(() -> Result.failure(new StockError.StockNotFound(stockId)));
};
}
}

View file

@ -0,0 +1,58 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.domain.inventory.StorageLocationId;
import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true)
public class ListStocks {
private final StockRepository stockRepository;
public ListStocks(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, List<Stock>> execute(String storageLocationId, String articleId) {
if (storageLocationId != null && articleId != null) {
return Result.failure(new StockError.InvalidArticleId(
"Only one filter parameter allowed: storageLocationId or articleId"));
}
if (storageLocationId != null) {
StorageLocationId locId;
try {
locId = StorageLocationId.of(storageLocationId);
} catch (IllegalArgumentException e) {
return Result.failure(new StockError.InvalidStorageLocationId(e.getMessage()));
}
return mapResult(stockRepository.findAllByStorageLocationId(locId));
}
if (articleId != null) {
ArticleId artId;
try {
artId = ArticleId.of(articleId);
} catch (IllegalArgumentException e) {
return Result.failure(new StockError.InvalidArticleId(e.getMessage()));
}
return mapResult(stockRepository.findAllByArticleId(artId));
}
return mapResult(stockRepository.findAll());
}
private Result<StockError, List<Stock>> mapResult(Result<RepositoryError, List<Stock>> result) {
return switch (result) {
case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message()));
case Result.Success(var stocks) -> Result.success(stocks);
};
}
}

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.util.List;
import java.util.Optional;
public interface StockRepository {
@ -14,5 +15,11 @@ public interface StockRepository {
Result<RepositoryError, Boolean> existsByArticleIdAndStorageLocationId(ArticleId articleId, StorageLocationId storageLocationId);
Result<RepositoryError, List<Stock>> findAll();
Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId);
Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId);
Result<RepositoryError, Void> save(Stock stock);
}

View file

@ -4,6 +4,8 @@ import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch;
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.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation;
@ -53,6 +55,16 @@ public class InventoryUseCaseConfiguration {
return new CreateStock(stockRepository);
}
@Bean
public GetStock getStock(StockRepository stockRepository) {
return new GetStock(stockRepository);
}
@Bean
public ListStocks listStocks(StockRepository stockRepository) {
return new ListStocks(stockRepository);
}
@Bean
public AddStockBatch addStockBatch(StockRepository stockRepository) {
return new AddStockBatch(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.util.List;
import java.util.Optional;
@Repository
@ -63,6 +64,45 @@ public class JpaStockRepository implements StockRepository {
}
}
@Override
public Result<RepositoryError, List<Stock>> findAll() {
try {
List<Stock> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAll", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId) {
try {
List<Stock> result = jpaRepository.findAllByStorageLocationId(storageLocationId.value()).stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAllByStorageLocationId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId) {
try {
List<Stock> result = jpaRepository.findAllByArticleId(articleId.value()).stream()
.map(mapper::toDomain)
.toList();
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findAllByArticleId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(Stock stock) {

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.infrastructure.inventory.persistence.entity.StockEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
@ -10,4 +11,8 @@ public interface StockJpaRepository extends JpaRepository<StockEntity, String> {
Optional<StockEntity> findByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
boolean existsByArticleIdAndStorageLocationId(String articleId, String storageLocationId);
List<StockEntity> findAllByStorageLocationId(String storageLocationId);
List<StockEntity> findAllByArticleId(String articleId);
}

View file

@ -3,6 +3,8 @@ package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.AddStockBatch;
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.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.command.AddStockBatchCommand;
@ -29,6 +31,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/inventory/stocks")
@SecurityRequirement(name = "Bearer Authentication")
@ -38,20 +42,55 @@ public class StockController {
private static final Logger logger = LoggerFactory.getLogger(StockController.class);
private final CreateStock createStock;
private final GetStock getStock;
private final ListStocks listStocks;
private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch;
public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
public StockController(CreateStock createStock, GetStock getStock, ListStocks listStocks,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
this.createStock = createStock;
this.getStock = getStock;
this.listStocks = listStocks;
this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch;
this.unblockStockBatch = unblockStockBatch;
}
@GetMapping
@PreAuthorize("hasAuthority('STOCK_READ')")
public ResponseEntity<List<StockResponse>> listStocks(
@RequestParam(required = false) String storageLocationId,
@RequestParam(required = false) String articleId
) {
var result = listStocks.execute(storageLocationId, articleId);
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) {
var result = getStock.execute(id);
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(StockResponse.from(result.unsafeGetValue()));
}
@PostMapping
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<StockResponse> createStock(

View file

@ -1,17 +1,23 @@
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;
import java.util.List;
@Schema(requiredProperties = {"id", "articleId", "storageLocationId"})
@Schema(requiredProperties = {"id", "articleId", "storageLocationId", "batches", "totalQuantity", "availableQuantity"})
public record StockResponse(
String id,
String articleId,
String storageLocationId,
@Schema(nullable = true) MinimumLevelResponse minimumLevel,
@Schema(nullable = true) Integer minimumShelfLifeDays
@Schema(nullable = true) Integer minimumShelfLifeDays,
List<StockBatchResponse> batches,
BigDecimal totalQuantity,
@Schema(nullable = true) String quantityUnit,
BigDecimal availableQuantity
) {
public static StockResponse from(Stock stock) {
@ -28,12 +34,33 @@ public record StockResponse(
shelfLifeDays = stock.minimumShelfLife().days();
}
List<StockBatchResponse> batchResponses = stock.batches().stream()
.map(StockBatchResponse::from)
.toList();
BigDecimal totalQuantity = stock.batches().stream()
.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);
String quantityUnit = stock.batches().isEmpty()
? null
: stock.batches().getFirst().quantity().uom().name();
return new StockResponse(
stock.id().value(),
stock.articleId().value(),
stock.storageLocationId().value(),
minimumLevel,
shelfLifeDays
shelfLifeDays,
batchResponses,
totalQuantity,
quantityUnit,
availableQuantity
);
}

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.util.List;
import java.util.Optional;
@Repository
@ -34,6 +35,21 @@ public class StubStockRepository implements StockRepository {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Stock>> findAll() {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Stock>> findAllByStorageLocationId(StorageLocationId storageLocationId) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Stock>> findAllByArticleId(ArticleId articleId) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(Stock stock) {
return Result.failure(STUB_ERROR);