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

feat(production): Chargen abfragen und suchen (GetBatch, ListBatches, FindBatchByNumber)

Full Vertical Slice für Batch-Lese-Endpoints:
- GET /api/production/batches/{id}
- GET /api/production/batches (Filter: status, productionDate, articleId)
- GET /api/production/batches/by-number/{batchNumber}

articleId-Filter löst über RecipeRepository.findByArticleId() die
zugehörigen Recipes auf und sucht dann Batches per findByRecipeIds().

Closes #34
This commit is contained in:
Sebastian Frick 2026-02-20 09:08:39 +01:00
parent fef3baa0ae
commit 1c65ac7795
20 changed files with 1348 additions and 1 deletions

View file

@ -0,0 +1,36 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public class FindBatchByNumber {
private final BatchRepository batchRepository;
private final AuthorizationPort authorizationPort;
public FindBatchByNumber(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
this.batchRepository = batchRepository;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, Batch> execute(BatchNumber batchNumber, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (batchRepository.findByBatchNumber(batchNumber)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new BatchError.BatchNotFoundByNumber(batchNumber));
}
return Result.success(opt.get());
}
}
}
}

View file

@ -0,0 +1,36 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public class GetBatch {
private final BatchRepository batchRepository;
private final AuthorizationPort authorizationPort;
public GetBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
this.batchRepository = batchRepository;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, Batch> execute(BatchId batchId, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (batchRepository.findById(batchId)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new BatchError.BatchNotFound(batchId));
}
return Result.success(opt.get());
}
}
}
}

View file

@ -0,0 +1,87 @@
package de.effigenix.application.production;
import de.effigenix.domain.production.*;
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.time.LocalDate;
import java.util.List;
@Transactional(readOnly = true)
public class ListBatches {
private final BatchRepository batchRepository;
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public ListBatches(BatchRepository batchRepository, RecipeRepository recipeRepository,
AuthorizationPort authorizationPort) {
this.batchRepository = batchRepository;
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, List<Batch>> execute(ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (batchRepository.findAll()) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
{ return Result.success(batches); }
}
}
public Result<BatchError, List<Batch>> executeByStatus(BatchStatus status, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (batchRepository.findByStatus(status)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
{ return Result.success(batches); }
}
}
public Result<BatchError, List<Batch>> executeByProductionDate(LocalDate date, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (batchRepository.findByProductionDate(date)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var batches) ->
{ return Result.success(batches); }
}
}
public Result<BatchError, List<Batch>> executeByArticleId(String articleId, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
switch (recipeRepository.findByArticleId(articleId)) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var recipes) -> {
if (recipes.isEmpty()) {
return Result.success(List.of());
}
List<RecipeId> recipeIds = recipes.stream().map(Recipe::id).toList();
switch (batchRepository.findByRecipeIds(recipeIds)) {
case Result.Failure(var batchErr) ->
{ return Result.failure(new BatchError.RepositoryFailure(batchErr.message())); }
case Result.Success(var batches) ->
{ return Result.success(batches); }
}
}
}
}
}

View file

@ -25,6 +25,11 @@ public sealed interface BatchError {
@Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; }
}
record BatchNotFoundByNumber(BatchNumber batchNumber) implements BatchError {
@Override public String code() { return "BATCH_NOT_FOUND_BY_NUMBER"; }
@Override public String message() { return "Batch with number '" + batchNumber.value() + "' not found"; }
}
record ValidationFailure(String message) implements BatchError {
@Override public String code() { return "BATCH_VALIDATION_ERROR"; }
}

View file

@ -3,6 +3,7 @@ package de.effigenix.domain.production;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@ -12,5 +13,13 @@ public interface BatchRepository {
Result<RepositoryError, List<Batch>> findAll();
Result<RepositoryError, Optional<Batch>> findByBatchNumber(BatchNumber batchNumber);
Result<RepositoryError, List<Batch>> findByStatus(BatchStatus status);
Result<RepositoryError, List<Batch>> findByProductionDate(LocalDate date);
Result<RepositoryError, List<Batch>> findByRecipeIds(List<RecipeId> recipeIds);
Result<RepositoryError, Void> save(Batch batch);
}

View file

@ -19,4 +19,6 @@ public interface RecipeRepository {
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status);
Result<RepositoryError, List<Recipe>> findByArticleId(String articleId);
}

View file

@ -5,6 +5,9 @@ import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecipeCycleChecker;
import de.effigenix.application.production.GetRecipe;
@ -77,4 +80,20 @@ public class ProductionUseCaseConfiguration {
BatchNumberGenerator batchNumberGenerator, AuthorizationPort authorizationPort) {
return new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authorizationPort);
}
@Bean
public GetBatch getBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new GetBatch(batchRepository, authorizationPort);
}
@Bean
public ListBatches listBatches(BatchRepository batchRepository, RecipeRepository recipeRepository,
AuthorizationPort authorizationPort) {
return new ListBatches(batchRepository, recipeRepository, authorizationPort);
}
@Bean
public FindBatchByNumber findBatchByNumber(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new FindBatchByNumber(batchRepository, authorizationPort);
}
}

View file

@ -3,5 +3,17 @@ package de.effigenix.infrastructure.production.persistence.repository;
import de.effigenix.infrastructure.production.persistence.entity.BatchEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface BatchJpaRepository extends JpaRepository<BatchEntity, String> {
Optional<BatchEntity> findByBatchNumber(String batchNumber);
List<BatchEntity> findByStatus(String status);
List<BatchEntity> findByProductionDate(LocalDate productionDate);
List<BatchEntity> findByRecipeIdIn(List<String> recipeIds);
}

View file

@ -10,6 +10,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@ -54,6 +55,58 @@ public class JpaBatchRepository implements BatchRepository {
}
}
@Override
public Result<RepositoryError, Optional<Batch>> findByBatchNumber(BatchNumber batchNumber) {
try {
Optional<Batch> result = jpaRepository.findByBatchNumber(batchNumber.value())
.map(mapper::toDomain);
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByBatchNumber", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Batch>> findByStatus(BatchStatus status) {
try {
List<Batch> result = jpaRepository.findByStatus(status.name()).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByStatus", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Batch>> findByProductionDate(LocalDate date) {
try {
List<Batch> result = jpaRepository.findByProductionDate(date).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByProductionDate", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, List<Batch>> findByRecipeIds(List<RecipeId> recipeIds) {
try {
List<String> ids = recipeIds.stream().map(RecipeId::value).toList();
List<Batch> result = jpaRepository.findByRecipeIdIn(ids).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByRecipeIds", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> save(Batch batch) {

View file

@ -91,6 +91,19 @@ public class JpaRecipeRepository implements RecipeRepository {
}
}
@Override
public Result<RepositoryError, List<Recipe>> findByArticleId(String articleId) {
try {
List<Recipe> result = jpaRepository.findByArticleId(articleId).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByArticleId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
try {

View file

@ -10,4 +10,6 @@ public interface RecipeJpaRepository extends JpaRepository<RecipeEntity, String>
List<RecipeEntity> findByStatus(String status);
boolean existsByNameAndVersion(String name, int version);
List<RecipeEntity> findByArticleId(String articleId);
}

View file

@ -1,9 +1,16 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.BatchId;
import de.effigenix.domain.production.BatchNumber;
import de.effigenix.domain.production.BatchStatus;
import de.effigenix.infrastructure.production.web.dto.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@ -11,12 +18,16 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/production/batches")
@SecurityRequirement(name = "Bearer Authentication")
@ -26,9 +37,91 @@ public class BatchController {
private static final Logger logger = LoggerFactory.getLogger(BatchController.class);
private final PlanBatch planBatch;
private final GetBatch getBatch;
private final ListBatches listBatches;
private final FindBatchByNumber findBatchByNumber;
public BatchController(PlanBatch planBatch) {
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber) {
this.planBatch = planBatch;
this.getBatch = getBatch;
this.listBatches = listBatches;
this.findBatchByNumber = findBatchByNumber;
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('BATCH_READ')")
public ResponseEntity<BatchResponse> getBatch(
@PathVariable("id") String id,
Authentication authentication
) {
var actorId = ActorId.of(authentication.getName());
var result = getBatch.execute(BatchId.of(id), actorId);
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
}
@GetMapping
@PreAuthorize("hasAuthority('BATCH_READ')")
public ResponseEntity<List<BatchSummaryResponse>> listBatches(
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "productionDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate productionDate,
@RequestParam(value = "articleId", required = false) String articleId,
Authentication authentication
) {
var actorId = ActorId.of(authentication.getName());
var result = switch (filterType(status, productionDate, articleId)) {
case "ambiguous" -> throw new BatchDomainErrorException(
new BatchError.ValidationFailure("Only one filter allowed at a time: status, productionDate, or articleId"));
case "status" -> {
try {
yield listBatches.executeByStatus(BatchStatus.valueOf(status), actorId);
} catch (IllegalArgumentException e) {
throw new BatchDomainErrorException(
new BatchError.ValidationFailure("Invalid status: " + status));
}
}
case "productionDate" -> listBatches.executeByProductionDate(productionDate, actorId);
case "articleId" -> listBatches.executeByArticleId(articleId, actorId);
default -> listBatches.execute(actorId);
};
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
var summaries = result.unsafeGetValue().stream()
.map(BatchSummaryResponse::from)
.toList();
return ResponseEntity.ok(summaries);
}
@GetMapping("/by-number/{batchNumber}")
@PreAuthorize("hasAuthority('BATCH_READ')")
public ResponseEntity<BatchResponse> findByNumber(
@PathVariable("batchNumber") String batchNumber,
Authentication authentication
) {
var actorId = ActorId.of(authentication.getName());
var parsedNumber = BatchNumber.of(batchNumber);
if (parsedNumber.isFailure()) {
throw new BatchDomainErrorException(
new BatchError.ValidationFailure("Invalid batch number format: " + batchNumber));
}
var result = findBatchByNumber.execute(parsedNumber.unsafeGetValue(), actorId);
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
}
@PostMapping
@ -57,6 +150,15 @@ public class BatchController {
.body(BatchResponse.from(result.unsafeGetValue()));
}
private static String filterType(String status, LocalDate productionDate, String articleId) {
int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0);
if (count > 1) return "ambiguous";
if (status != null) return "status";
if (productionDate != null) return "productionDate";
if (articleId != null) return "articleId";
return "none";
}
public static class BatchDomainErrorException extends RuntimeException {
private final BatchError error;

View file

@ -0,0 +1,34 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Batch;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public record BatchSummaryResponse(
String id,
String batchNumber,
String recipeId,
String status,
String plannedQuantity,
String plannedQuantityUnit,
LocalDate productionDate,
LocalDate bestBeforeDate,
OffsetDateTime createdAt,
OffsetDateTime updatedAt
) {
public static BatchSummaryResponse from(Batch batch) {
return new BatchSummaryResponse(
batch.id().value(),
batch.batchNumber().value(),
batch.recipeId().value(),
batch.status().name(),
batch.plannedQuantity().amount().toPlainString(),
batch.plannedQuantity().uom().name(),
batch.productionDate(),
batch.bestBeforeDate(),
batch.createdAt(),
batch.updatedAt()
);
}
}

View file

@ -29,6 +29,7 @@ public final class ProductionErrorHttpStatusMapper {
public static int toHttpStatus(BatchError error) {
return switch (error) {
case BatchError.BatchNotFound e -> 404;
case BatchError.BatchNotFoundByNumber e -> 404;
case BatchError.InvalidPlannedQuantity e -> 400;
case BatchError.InvalidDates e -> 400;
case BatchError.RecipeNotActive e -> 409;

View file

@ -48,4 +48,9 @@ public class StubRecipeRepository implements RecipeRepository {
public Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Recipe>> findByArticleId(String articleId) {
return Result.failure(STUB_ERROR);
}
}