From b06157b92c5b230184ada26b00f353007c453aed Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 23:51:36 +0100 Subject: [PATCH] feat(production): Charge planen (PlanBatch) (#33) Batch-Aggregat mit plan()-Factory, automatischer BatchNumber-Generierung (P-YYYY-MM-DD-XXX) und Validierung (Quantity > 0, bestBeforeDate > productionDate). Full Vertical Slice: Domain, Application, Infrastructure (JPA, REST, Liquibase). --- .../application/production/PlanBatch.java | 86 +++++ .../production/command/PlanBatchCommand.java | 11 + .../de/effigenix/domain/production/Batch.java | 128 +++++++ .../domain/production/BatchDraft.java | 11 + .../domain/production/BatchError.java | 39 +++ .../effigenix/domain/production/BatchId.java | 20 ++ .../production/BatchNumberGenerator.java | 10 + .../domain/production/BatchRepository.java | 16 + .../domain/production/BatchStatus.java | 8 + .../ProductionUseCaseConfiguration.java | 9 + .../persistence/JpaBatchNumberGenerator.java | 37 ++ .../persistence/entity/BatchEntity.java | 80 +++++ .../persistence/mapper/BatchMapper.java | 44 +++ .../repository/BatchJpaRepository.java | 13 + .../repository/JpaBatchRepository.java | 68 ++++ .../web/controller/BatchController.java | 72 ++++ .../production/web/dto/BatchResponse.java | 34 ++ .../production/web/dto/PlanBatchRequest.java | 14 + .../ProductionErrorHttpStatusMapper.java | 13 + .../web/exception/GlobalExceptionHandler.java | 25 ++ .../changes/015-create-batches-table.xml | 60 ++++ .../db/changelog/db.changelog-master.xml | 1 + .../application/production/PlanBatchTest.java | 216 ++++++++++++ .../domain/production/BatchTest.java | 198 +++++++++++ .../web/BatchControllerIntegrationTest.java | 328 ++++++++++++++++++ 25 files changed, 1541 insertions(+) create mode 100644 backend/src/main/java/de/effigenix/application/production/PlanBatch.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/PlanBatchCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/Batch.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchError.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchId.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchNumberGenerator.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchRepository.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/BatchStatus.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/JpaBatchNumberGenerator.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/PlanBatchRequest.java create mode 100644 backend/src/main/resources/db/changelog/changes/015-create-batches-table.xml create mode 100644 backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/BatchTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/PlanBatch.java b/backend/src/main/java/de/effigenix/application/production/PlanBatch.java new file mode 100644 index 0000000..e3c65f2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/PlanBatch.java @@ -0,0 +1,86 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.PlanBatchCommand; +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 +public class PlanBatch { + + private final BatchRepository batchRepository; + private final RecipeRepository recipeRepository; + private final BatchNumberGenerator batchNumberGenerator; + private final AuthorizationPort authorizationPort; + + public PlanBatch( + BatchRepository batchRepository, + RecipeRepository recipeRepository, + BatchNumberGenerator batchNumberGenerator, + AuthorizationPort authorizationPort + ) { + this.batchRepository = batchRepository; + this.recipeRepository = recipeRepository; + this.batchNumberGenerator = batchNumberGenerator; + this.authorizationPort = authorizationPort; + } + + public Result execute(PlanBatchCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_WRITE)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to plan batches")); + } + + // Verify recipe exists and is ACTIVE + Recipe recipe; + switch (recipeRepository.findById(RecipeId.of(cmd.recipeId()))) { + 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.ValidationFailure( + "Recipe with ID '" + cmd.recipeId() + "' not found")); + } + recipe = opt.get(); + } + } + + if (recipe.status() != RecipeStatus.ACTIVE) { + return Result.failure(new BatchError.RecipeNotActive(recipe.id())); + } + + // Generate batch number + BatchNumber batchNumber; + switch (batchNumberGenerator.generateNext(cmd.productionDate())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> batchNumber = val; + } + + // Plan batch + var draft = new BatchDraft( + cmd.recipeId(), + cmd.plannedQuantity(), + cmd.plannedQuantityUnit(), + cmd.productionDate(), + cmd.bestBeforeDate() + ); + + Batch batch; + switch (Batch.plan(draft, batchNumber)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> batch = val; + } + + // Persist + switch (batchRepository.save(batch)) { + case Result.Failure(var err) -> { + return Result.failure(new BatchError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + return Result.success(batch); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/PlanBatchCommand.java b/backend/src/main/java/de/effigenix/application/production/command/PlanBatchCommand.java new file mode 100644 index 0000000..8e90191 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/PlanBatchCommand.java @@ -0,0 +1,11 @@ +package de.effigenix.application.production.command; + +import java.time.LocalDate; + +public record PlanBatchCommand( + String recipeId, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate productionDate, + LocalDate bestBeforeDate +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/Batch.java b/backend/src/main/java/de/effigenix/domain/production/Batch.java new file mode 100644 index 0000000..30eabde --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/Batch.java @@ -0,0 +1,128 @@ +package de.effigenix.domain.production; + +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.time.LocalDateTime; + +/** + * Batch aggregate root. + * + * Invariants: + * 1. PlannedQuantity must be positive + * 2. BestBeforeDate must be after ProductionDate + * 3. BatchNumber is auto-generated (format P-YYYY-MM-DD-XXX) + * 4. New batches always start in PLANNED status + * 5. RecipeId must reference an ACTIVE recipe (enforced by Use Case) + */ +public class Batch { + + private final BatchId id; + private final BatchNumber batchNumber; + private final RecipeId recipeId; + private BatchStatus status; + private final Quantity plannedQuantity; + private final LocalDate productionDate; + private final LocalDate bestBeforeDate; + private final LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private Batch( + BatchId id, + BatchNumber batchNumber, + RecipeId recipeId, + BatchStatus status, + Quantity plannedQuantity, + LocalDate productionDate, + LocalDate bestBeforeDate, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + this.id = id; + this.batchNumber = batchNumber; + this.recipeId = recipeId; + this.status = status; + this.plannedQuantity = plannedQuantity; + this.productionDate = productionDate; + this.bestBeforeDate = bestBeforeDate; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static Result plan(BatchDraft draft, BatchNumber batchNumber) { + if (draft.recipeId() == null || draft.recipeId().isBlank()) { + return Result.failure(new BatchError.ValidationFailure("recipeId must not be blank")); + } + + if (draft.productionDate() == null) { + return Result.failure(new BatchError.ValidationFailure("productionDate must not be null")); + } + + if (draft.bestBeforeDate() == null) { + return Result.failure(new BatchError.ValidationFailure("bestBeforeDate must not be null")); + } + + if (!draft.bestBeforeDate().isAfter(draft.productionDate())) { + return Result.failure(new BatchError.InvalidDates( + "bestBeforeDate (" + draft.bestBeforeDate() + ") must be after productionDate (" + draft.productionDate() + ")")); + } + + Quantity plannedQuantity; + try { + var amount = new BigDecimal(draft.plannedQuantity()); + var uom = UnitOfMeasure.valueOf(draft.plannedQuantityUnit()); + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new BatchError.InvalidPlannedQuantity(err.toString())); + } + case Result.Success(var qty) -> plannedQuantity = qty; + } + } catch (NumberFormatException e) { + return Result.failure(new BatchError.InvalidPlannedQuantity( + "Invalid amount format: " + draft.plannedQuantity())); + } catch (IllegalArgumentException e) { + return Result.failure(new BatchError.InvalidPlannedQuantity( + "Invalid unit: " + draft.plannedQuantityUnit())); + } + + var now = LocalDateTime.now(); + return Result.success(new Batch( + BatchId.generate(), + batchNumber, + RecipeId.of(draft.recipeId()), + BatchStatus.PLANNED, + plannedQuantity, + draft.productionDate(), + draft.bestBeforeDate(), + now, + now + )); + } + + public static Batch reconstitute( + BatchId id, + BatchNumber batchNumber, + RecipeId recipeId, + BatchStatus status, + Quantity plannedQuantity, + LocalDate productionDate, + LocalDate bestBeforeDate, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate, bestBeforeDate, createdAt, updatedAt); + } + + public BatchId id() { return id; } + public BatchNumber batchNumber() { return batchNumber; } + public RecipeId recipeId() { return recipeId; } + public BatchStatus status() { return status; } + public Quantity plannedQuantity() { return plannedQuantity; } + public LocalDate productionDate() { return productionDate; } + public LocalDate bestBeforeDate() { return bestBeforeDate; } + public LocalDateTime createdAt() { return createdAt; } + public LocalDateTime updatedAt() { return updatedAt; } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchDraft.java b/backend/src/main/java/de/effigenix/domain/production/BatchDraft.java new file mode 100644 index 0000000..73e195b --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchDraft.java @@ -0,0 +1,11 @@ +package de.effigenix.domain.production; + +import java.time.LocalDate; + +public record BatchDraft( + String recipeId, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate productionDate, + LocalDate bestBeforeDate +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchError.java b/backend/src/main/java/de/effigenix/domain/production/BatchError.java new file mode 100644 index 0000000..375b0ee --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchError.java @@ -0,0 +1,39 @@ +package de.effigenix.domain.production; + +public sealed interface BatchError { + + String code(); + String message(); + + record BatchNotFound(BatchId id) implements BatchError { + @Override public String code() { return "BATCH_NOT_FOUND"; } + @Override public String message() { return "Batch with ID '" + id.value() + "' not found"; } + } + + record InvalidPlannedQuantity(String reason) implements BatchError { + @Override public String code() { return "BATCH_INVALID_PLANNED_QUANTITY"; } + @Override public String message() { return "Invalid planned quantity: " + reason; } + } + + record InvalidDates(String reason) implements BatchError { + @Override public String code() { return "BATCH_INVALID_DATES"; } + @Override public String message() { return "Invalid dates: " + reason; } + } + + record RecipeNotActive(RecipeId recipeId) implements BatchError { + @Override public String code() { return "BATCH_RECIPE_NOT_ACTIVE"; } + @Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; } + } + + record ValidationFailure(String message) implements BatchError { + @Override public String code() { return "BATCH_VALIDATION_ERROR"; } + } + + record Unauthorized(String message) implements BatchError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements BatchError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchId.java b/backend/src/main/java/de/effigenix/domain/production/BatchId.java new file mode 100644 index 0000000..d3f87ff --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.production; + +import java.util.UUID; + +public record BatchId(String value) { + + public BatchId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("BatchId must not be blank"); + } + } + + public static BatchId generate() { + return new BatchId(UUID.randomUUID().toString()); + } + + public static BatchId of(String value) { + return new BatchId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchNumberGenerator.java b/backend/src/main/java/de/effigenix/domain/production/BatchNumberGenerator.java new file mode 100644 index 0000000..33d006c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchNumberGenerator.java @@ -0,0 +1,10 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.time.LocalDate; + +public interface BatchNumberGenerator { + + Result generateNext(LocalDate date); +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java new file mode 100644 index 0000000..2d56e77 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchRepository.java @@ -0,0 +1,16 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; + +import java.util.List; +import java.util.Optional; + +public interface BatchRepository { + + Result> findById(BatchId id); + + Result> findAll(); + + Result save(Batch batch); +} diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchStatus.java b/backend/src/main/java/de/effigenix/domain/production/BatchStatus.java new file mode 100644 index 0000000..947ac20 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/BatchStatus.java @@ -0,0 +1,8 @@ +package de.effigenix.domain.production; + +public enum BatchStatus { + PLANNED, + IN_PRODUCTION, + COMPLETED, + CANCELLED +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index bcd0bb7..65bd7fb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -5,11 +5,14 @@ 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.PlanBatch; import de.effigenix.application.production.RecipeCycleChecker; import de.effigenix.application.production.GetRecipe; import de.effigenix.application.production.ListRecipes; import de.effigenix.application.production.RemoveProductionStep; import de.effigenix.application.production.RemoveRecipeIngredient; +import de.effigenix.domain.production.BatchNumberGenerator; +import de.effigenix.domain.production.BatchRepository; import de.effigenix.domain.production.RecipeRepository; import de.effigenix.shared.security.AuthorizationPort; import org.springframework.context.annotation.Bean; @@ -68,4 +71,10 @@ public class ProductionUseCaseConfiguration { public ArchiveRecipe archiveRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { return new ArchiveRecipe(recipeRepository, authorizationPort); } + + @Bean + public PlanBatch planBatch(BatchRepository batchRepository, RecipeRepository recipeRepository, + BatchNumberGenerator batchNumberGenerator, AuthorizationPort authorizationPort) { + return new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JpaBatchNumberGenerator.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JpaBatchNumberGenerator.java new file mode 100644 index 0000000..8bd158d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/JpaBatchNumberGenerator.java @@ -0,0 +1,37 @@ +package de.effigenix.infrastructure.production.persistence; + +import de.effigenix.domain.production.BatchError; +import de.effigenix.domain.production.BatchNumber; +import de.effigenix.domain.production.BatchNumberGenerator; +import de.effigenix.infrastructure.production.persistence.repository.BatchJpaRepository; +import de.effigenix.shared.common.Result; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@Profile("!no-db") +public class JpaBatchNumberGenerator implements BatchNumberGenerator { + + private final BatchJpaRepository batchJpaRepository; + + public JpaBatchNumberGenerator(BatchJpaRepository batchJpaRepository) { + this.batchJpaRepository = batchJpaRepository; + } + + @Override + public Result generateNext(LocalDate date) { + try { + int count = batchJpaRepository.countByProductionDate(date); + int nextSequence = count + 1; + if (nextSequence > 999) { + return Result.failure(new BatchError.ValidationFailure( + "Maximum batch number sequence (999) reached for date " + date)); + } + return Result.success(BatchNumber.generate(date, nextSequence)); + } catch (Exception e) { + return Result.failure(new BatchError.RepositoryFailure(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java new file mode 100644 index 0000000..ea6a502 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java @@ -0,0 +1,80 @@ +package de.effigenix.infrastructure.production.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "batches") +public class BatchEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "batch_number", nullable = false, unique = true, length = 20) + private String batchNumber; + + @Column(name = "recipe_id", nullable = false, length = 36) + private String recipeId; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "planned_quantity_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal plannedQuantityAmount; + + @Column(name = "planned_quantity_unit", nullable = false, length = 10) + private String plannedQuantityUnit; + + @Column(name = "production_date", nullable = false) + private LocalDate productionDate; + + @Column(name = "best_before_date", nullable = false) + private LocalDate bestBeforeDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected BatchEntity() {} + + public BatchEntity( + String id, + String batchNumber, + String recipeId, + String status, + BigDecimal plannedQuantityAmount, + String plannedQuantityUnit, + LocalDate productionDate, + LocalDate bestBeforeDate, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + this.id = id; + this.batchNumber = batchNumber; + this.recipeId = recipeId; + this.status = status; + this.plannedQuantityAmount = plannedQuantityAmount; + this.plannedQuantityUnit = plannedQuantityUnit; + this.productionDate = productionDate; + this.bestBeforeDate = bestBeforeDate; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getId() { return id; } + public String getBatchNumber() { return batchNumber; } + public String getRecipeId() { return recipeId; } + public String getStatus() { return status; } + public BigDecimal getPlannedQuantityAmount() { return plannedQuantityAmount; } + public String getPlannedQuantityUnit() { return plannedQuantityUnit; } + public LocalDate getProductionDate() { return productionDate; } + public LocalDate getBestBeforeDate() { return bestBeforeDate; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java new file mode 100644 index 0000000..cf3c33a --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java @@ -0,0 +1,44 @@ +package de.effigenix.infrastructure.production.persistence.mapper; + +import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.entity.BatchEntity; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +import org.springframework.stereotype.Component; + +@Component +public class BatchMapper { + + public BatchEntity toEntity(Batch batch) { + return new BatchEntity( + batch.id().value(), + batch.batchNumber().value(), + batch.recipeId().value(), + batch.status().name(), + batch.plannedQuantity().amount(), + batch.plannedQuantity().uom().name(), + batch.productionDate(), + batch.bestBeforeDate(), + batch.createdAt(), + batch.updatedAt() + ); + } + + public Batch toDomain(BatchEntity entity) { + return Batch.reconstitute( + BatchId.of(entity.getId()), + new BatchNumber(entity.getBatchNumber()), + RecipeId.of(entity.getRecipeId()), + BatchStatus.valueOf(entity.getStatus()), + Quantity.reconstitute( + entity.getPlannedQuantityAmount(), + UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()), + null, null + ), + entity.getProductionDate(), + entity.getBestBeforeDate(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java new file mode 100644 index 0000000..622114b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/BatchJpaRepository.java @@ -0,0 +1,13 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.infrastructure.production.persistence.entity.BatchEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDate; + +public interface BatchJpaRepository extends JpaRepository { + + @Query("SELECT COUNT(b) FROM BatchEntity b WHERE b.productionDate = :date") + int countByProductionDate(LocalDate date); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java new file mode 100644 index 0000000..5c2133c --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaBatchRepository.java @@ -0,0 +1,68 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.mapper.BatchMapper; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Repository +@Profile("!no-db") +@Transactional(readOnly = true) +public class JpaBatchRepository implements BatchRepository { + + private static final Logger logger = LoggerFactory.getLogger(JpaBatchRepository.class); + + private final BatchJpaRepository jpaRepository; + private final BatchMapper mapper; + + public JpaBatchRepository(BatchJpaRepository jpaRepository, BatchMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Result> findById(BatchId id) { + try { + Optional result = jpaRepository.findById(id.value()) + .map(mapper::toDomain); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findById", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAll() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomain) + .collect(Collectors.toList()); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + @Transactional + public Result save(Batch batch) { + try { + jpaRepository.save(mapper.toEntity(batch)); + return Result.success(null); + } catch (Exception e) { + logger.trace("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java new file mode 100644 index 0000000..d30a33e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java @@ -0,0 +1,72 @@ +package de.effigenix.infrastructure.production.web.controller; + +import de.effigenix.application.production.PlanBatch; +import de.effigenix.application.production.command.PlanBatchCommand; +import de.effigenix.domain.production.BatchError; +import de.effigenix.infrastructure.production.web.dto.BatchResponse; +import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; +import de.effigenix.shared.security.ActorId; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.*; + +@RestController +@RequestMapping("/api/production/batches") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Batches", description = "Production batch management endpoints") +public class BatchController { + + private static final Logger logger = LoggerFactory.getLogger(BatchController.class); + + private final PlanBatch planBatch; + + public BatchController(PlanBatch planBatch) { + this.planBatch = planBatch; + } + + @PostMapping + @PreAuthorize("hasAuthority('BATCH_WRITE')") + public ResponseEntity planBatch( + @Valid @RequestBody PlanBatchRequest request, + Authentication authentication + ) { + logger.info("Planning batch for recipe: {} by actor: {}", request.recipeId(), authentication.getName()); + + var cmd = new PlanBatchCommand( + request.recipeId(), + request.plannedQuantity(), + request.plannedQuantityUnit(), + request.productionDate(), + request.bestBeforeDate() + ); + + var result = planBatch.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new BatchDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.status(HttpStatus.CREATED) + .body(BatchResponse.from(result.unsafeGetValue())); + } + + public static class BatchDomainErrorException extends RuntimeException { + private final BatchError error; + + public BatchDomainErrorException(BatchError error) { + super(error.message()); + this.error = error; + } + + public BatchError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java new file mode 100644 index 0000000..81fefd7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java @@ -0,0 +1,34 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.Batch; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record BatchResponse( + String id, + String batchNumber, + String recipeId, + String status, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate productionDate, + LocalDate bestBeforeDate, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static BatchResponse from(Batch batch) { + return new BatchResponse( + 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() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/PlanBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/PlanBatchRequest.java new file mode 100644 index 0000000..e3d0d1f --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/PlanBatchRequest.java @@ -0,0 +1,14 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record PlanBatchRequest( + @NotBlank String recipeId, + @NotBlank String plannedQuantity, + @NotBlank String plannedQuantityUnit, + @NotNull LocalDate productionDate, + @NotNull LocalDate bestBeforeDate +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index 5ae5937..403baf2 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.production.web.exception; +import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.RecipeError; public final class ProductionErrorHttpStatusMapper { @@ -24,4 +25,16 @@ public final class ProductionErrorHttpStatusMapper { case RecipeError.RepositoryFailure e -> 500; }; } + + public static int toHttpStatus(BatchError error) { + return switch (error) { + case BatchError.BatchNotFound e -> 404; + case BatchError.InvalidPlannedQuantity e -> 400; + case BatchError.InvalidDates e -> 400; + case BatchError.RecipeNotActive e -> 409; + case BatchError.ValidationFailure e -> 400; + case BatchError.Unauthorized e -> 403; + case BatchError.RepositoryFailure e -> 500; + }; + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index 8bfd7ee..023c4ea 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import de.effigenix.domain.masterdata.ArticleError; import de.effigenix.domain.masterdata.ProductCategoryError; import de.effigenix.domain.masterdata.CustomerError; import de.effigenix.domain.masterdata.SupplierError; +import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.RecipeError; import de.effigenix.domain.usermanagement.UserError; import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; @@ -15,6 +16,7 @@ import de.effigenix.infrastructure.masterdata.web.controller.ArticleController; import de.effigenix.infrastructure.masterdata.web.controller.CustomerController; import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController; import de.effigenix.infrastructure.masterdata.web.controller.SupplierController; +import de.effigenix.infrastructure.production.web.controller.BatchController; import de.effigenix.infrastructure.production.web.controller.RecipeController; import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper; import de.effigenix.shared.common.RepositoryError; @@ -248,6 +250,29 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(BatchController.BatchDomainErrorException.class) + public ResponseEntity handleBatchDomainError( + BatchController.BatchDomainErrorException ex, + HttpServletRequest request + ) { + BatchError error = ex.getError(); + int status = ProductionErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("Batch", error.code(), error.message(), status); + + String clientMessage = status >= 500 + ? "An internal error occurred" + : error.message(); + + ErrorResponse errorResponse = ErrorResponse.from( + error.code(), + clientMessage, + status, + request.getRequestURI() + ); + + return ResponseEntity.status(status).body(errorResponse); + } + @ExceptionHandler(RoleController.RoleDomainErrorException.class) public ResponseEntity handleRoleDomainError( RoleController.RoleDomainErrorException ex, diff --git a/backend/src/main/resources/db/changelog/changes/015-create-batches-table.xml b/backend/src/main/resources/db/changelog/changes/015-create-batches-table.xml new file mode 100644 index 0000000..7d23cc0 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/015-create-batches-table.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE batches ADD CONSTRAINT chk_batch_status CHECK (status IN ('PLANNED', 'IN_PRODUCTION', 'COMPLETED', 'CANCELLED')); + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index a3a1b2d..75cea8c 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -19,5 +19,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java b/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java new file mode 100644 index 0000000..4685a51 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java @@ -0,0 +1,216 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.PlanBatchCommand; +import de.effigenix.domain.production.*; +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 de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PlanBatch Use Case") +class PlanBatchTest { + + @Mock private BatchRepository batchRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private BatchNumberGenerator batchNumberGenerator; + @Mock private AuthorizationPort authPort; + + private PlanBatch planBatch; + private ActorId performedBy; + + private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1); + private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1); + private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1); + + @BeforeEach + void setUp() { + planBatch = new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authPort); + performedBy = ActorId.of("admin-user"); + } + + private PlanBatchCommand validCommand() { + return new PlanBatchCommand("recipe-1", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + } + + private Recipe activeRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), List.of(), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Recipe draftRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.DRAFT, List.of(), List.of(), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("should plan batch when recipe is ACTIVE and all data valid") + void should_PlanBatch_When_ValidCommand() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(batchNumberGenerator.generateNext(PRODUCTION_DATE)) + .thenReturn(Result.success(BATCH_NUMBER)); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + var batch = result.unsafeGetValue(); + assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED); + assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER); + assertThat(batch.recipeId().value()).isEqualTo("recipe-1"); + verify(batchRepository).save(any(Batch.class)); + } + + @Test + @DisplayName("should fail when actor lacks BATCH_WRITE permission") + void should_Fail_When_Unauthorized() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe not found") + void should_Fail_When_RecipeNotFound() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + assertThat(result.unsafeGetError().message()).contains("recipe-1"); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe is not ACTIVE") + void should_Fail_When_RecipeNotActive() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(draftRecipe()))); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RecipeNotActive.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe repository returns error") + void should_Fail_When_RecipeRepositoryError() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch number generation fails") + void should_Fail_When_BatchNumberGenerationFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(batchNumberGenerator.generateNext(PRODUCTION_DATE)) + .thenReturn(Result.failure(new BatchError.ValidationFailure("Sequence exhausted"))); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch repository save fails") + void should_Fail_When_BatchSaveFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(batchNumberGenerator.generateNext(PRODUCTION_DATE)) + .thenReturn(Result.success(BATCH_NUMBER)); + when(batchRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full"))); + + var result = planBatch.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when planned quantity is invalid") + void should_Fail_When_InvalidQuantity() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(batchNumberGenerator.generateNext(PRODUCTION_DATE)) + .thenReturn(Result.success(BATCH_NUMBER)); + + var cmd = new PlanBatchCommand("recipe-1", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var result = planBatch.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when dates are invalid") + void should_Fail_When_InvalidDates() { + when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(batchNumberGenerator.generateNext(LocalDate.of(2026, 6, 1))) + .thenReturn(Result.success(BatchNumber.generate(LocalDate.of(2026, 6, 1), 1))); + + var cmd = new PlanBatchCommand("recipe-1", "100", "KILOGRAM", + LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1)); + var result = planBatch.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class); + verify(batchRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchTest.java new file mode 100644 index 0000000..38d1e68 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/BatchTest.java @@ -0,0 +1,198 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +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.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Batch Aggregate") +class BatchTest { + + private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1); + private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1); + private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1); + + private BatchDraft validDraft() { + return new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + } + + @Nested + @DisplayName("plan()") + class Plan { + + @Test + @DisplayName("should create batch with valid draft") + void should_CreateBatch_When_ValidDraft() { + var result = Batch.plan(validDraft(), BATCH_NUMBER); + + assertThat(result.isSuccess()).isTrue(); + var batch = result.unsafeGetValue(); + assertThat(batch.id()).isNotNull(); + assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER); + assertThat(batch.recipeId().value()).isEqualTo("recipe-123"); + assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED); + assertThat(batch.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100")); + assertThat(batch.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(batch.productionDate()).isEqualTo(PRODUCTION_DATE); + assertThat(batch.bestBeforeDate()).isEqualTo(BEST_BEFORE_DATE); + assertThat(batch.createdAt()).isNotNull(); + assertThat(batch.updatedAt()).isNotNull(); + } + + @Test + @DisplayName("should fail when recipeId is blank") + void should_Fail_When_RecipeIdBlank() { + var draft = new BatchDraft("", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when recipeId is null") + void should_Fail_When_RecipeIdNull() { + var draft = new BatchDraft(null, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is zero") + void should_Fail_When_QuantityZero() { + var draft = new BatchDraft("recipe-123", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is negative") + void should_Fail_When_QuantityNegative() { + var draft = new BatchDraft("recipe-123", "-5", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is not a number") + void should_Fail_When_QuantityNotANumber() { + var draft = new BatchDraft("recipe-123", "abc", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when unit is invalid") + void should_Fail_When_UnitInvalid() { + var draft = new BatchDraft("recipe-123", "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when bestBeforeDate is before productionDate") + void should_Fail_When_BestBeforeDateBeforeProductionDate() { + var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", + LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1)); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class); + } + + @Test + @DisplayName("should fail when bestBeforeDate equals productionDate") + void should_Fail_When_BestBeforeDateEqualsProductionDate() { + var sameDate = LocalDate.of(2026, 3, 1); + var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", sameDate, sameDate); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class); + } + + @Test + @DisplayName("should fail when productionDate is null") + void should_Fail_When_ProductionDateNull() { + var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", null, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when bestBeforeDate is null") + void should_Fail_When_BestBeforeDateNull() { + var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, null); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class); + } + + @Test + @DisplayName("should accept decimal quantity") + void should_Accept_When_DecimalQuantity() { + var draft = new BatchDraft("recipe-123", "50.75", "LITER", PRODUCTION_DATE, BEST_BEFORE_DATE); + + var result = Batch.plan(draft, BATCH_NUMBER); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().plannedQuantity().amount()) + .isEqualByComparingTo(new BigDecimal("50.75")); + assertThat(result.unsafeGetValue().plannedQuantity().uom()).isEqualTo(UnitOfMeasure.LITER); + } + } + + @Nested + @DisplayName("reconstitute()") + class Reconstitute { + + @Test + @DisplayName("should reconstitute batch from persistence") + void should_Reconstitute_When_ValidData() { + var batch = Batch.reconstitute( + BatchId.of("batch-1"), + BATCH_NUMBER, + RecipeId.of("recipe-123"), + BatchStatus.IN_PRODUCTION, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PRODUCTION_DATE, + BEST_BEFORE_DATE, + LocalDateTime.now(), + LocalDateTime.now() + ); + + assertThat(batch.id().value()).isEqualTo("batch-1"); + assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION); + } + } +} diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java new file mode 100644 index 0000000..fc98700 --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java @@ -0,0 +1,328 @@ +package de.effigenix.infrastructure.production.web; + +import de.effigenix.domain.usermanagement.RoleName; +import de.effigenix.infrastructure.AbstractIntegrationTest; +import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; +import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; +import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("Batch Controller Integration Tests") +class BatchControllerIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + + private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1); + private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1); + + @BeforeEach + void setUp() throws Exception { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01"); + UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); + + adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ"); + viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ"); + } + + @Nested + @DisplayName("POST /api/production/batches – Charge planen") + class PlanBatchEndpoint { + + @Test + @DisplayName("Charge planen mit gültigen Daten → 201") + void planBatch_withValidData_returns201() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.batchNumber").isNotEmpty()) + .andExpect(jsonPath("$.recipeId").value(recipeId)) + .andExpect(jsonPath("$.status").value("PLANNED")) + .andExpect(jsonPath("$.plannedQuantity").value("100.000000")) + .andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.productionDate").value("2026-03-01")) + .andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01")) + .andExpect(jsonPath("$.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.updatedAt").isNotEmpty()); + } + + @Test + @DisplayName("BatchNumber hat Format P-YYYY-MM-DD-XXX") + void planBatch_batchNumberFormat() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001")); + } + + @Test + @DisplayName("Zweite Charge am selben Tag → inkrementierte Sequenznummer") + void planBatch_secondBatchSameDay_incrementsSequence() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001")); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-002")); + } + + @Test + @DisplayName("PlannedQuantity 0 → 400") + void planBatch_zeroQuantity_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("Negative Menge → 400") + void planBatch_negativeQuantity_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "-10", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("Ungültige Unit → 400") + void planBatch_invalidUnit_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("BestBeforeDate vor ProductionDate → 400") + void planBatch_bestBeforeBeforeProduction_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", + LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1)); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES")); + } + + @Test + @DisplayName("BestBeforeDate gleich ProductionDate → 400") + void planBatch_sameDate_returns400() throws Exception { + String recipeId = createActiveRecipe(); + var sameDate = LocalDate.of(2026, 3, 1); + + var request = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", sameDate, sameDate); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES")); + } + + @Test + @DisplayName("Rezept nicht gefunden → 400") + void planBatch_recipeNotFound_returns400() throws Exception { + var request = new PlanBatchRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Rezept nicht ACTIVE (DRAFT) → 409") + void planBatch_recipeNotActive_returns409() throws Exception { + String recipeId = createDraftRecipe(); + + var request = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_RECIPE_NOT_ACTIVE")); + } + + @Test + @DisplayName("recipeId leer → 400 (Bean Validation)") + void planBatch_blankRecipeId_returns400() throws Exception { + var request = new PlanBatchRequest( + "", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("plannedQuantity leer → 400 (Bean Validation)") + void planBatch_blankQuantity_returns400() throws Exception { + var request = new PlanBatchRequest( + UUID.randomUUID().toString(), "", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Authorization") + class AuthTests { + + @Test + @DisplayName("Ohne BATCH_WRITE → 403") + void planBatch_withViewerToken_returns403() throws Exception { + var request = new PlanBatchRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void planBatch_withoutToken_returns401() throws Exception { + var request = new PlanBatchRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + + mockMvc.perform(post("/api/production/batches") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + // ==================== Hilfsmethoden ==================== + + private String createActiveRecipe() throws Exception { + String recipeId = createDraftRecipe(); + + // Add ingredient (required for activation) + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + // Activate + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return recipeId; + } + + private String createDraftRecipe() throws Exception { + String json = """ + { + "name": "Test-Rezept-%s", + "version": 1, + "type": "FINISHED_PRODUCT", + "description": "Testrezept", + "yieldPercentage": 85, + "shelfLifeDays": 14, + "outputQuantity": "100", + "outputUom": "KILOGRAM" + } + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/recipes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } +}