From 9b9b7311d1bb86a97e31daae59bb9ec67768bac3 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 10:12:04 +0100 Subject: [PATCH] feat(production): Recipe Aggregate als Full Vertical Slice (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain: Recipe Aggregate Root mit create(RecipeDraft), RecipeId, RecipeName, RecipeType, RecipeStatus, RecipeError, RecipeRepository Interface. Application: CreateRecipe Use Case mit Name+Version Uniqueness-Check. Infrastructure: JPA Entity/Mapper/Repository, REST POST /api/recipes, Liquibase Migration, ProductionErrorHttpStatusMapper, Spring Config. Tests: 15 Unit Tests für Recipe Aggregate (75 total im Production BC). --- .../application/production/CreateRecipe.java | 50 ++++ .../command/CreateRecipeCommand.java | 14 ++ .../effigenix/domain/production/Recipe.java | 172 +++++++++++++ .../domain/production/RecipeDraft.java | 25 ++ .../domain/production/RecipeError.java | 34 +++ .../effigenix/domain/production/RecipeId.java | 20 ++ .../domain/production/RecipeName.java | 23 ++ .../domain/production/RecipeRepository.java | 20 ++ .../domain/production/RecipeStatus.java | 7 + .../domain/production/RecipeType.java | 7 + .../ProductionUseCaseConfiguration.java | 15 ++ .../persistence/entity/RecipeEntity.java | 94 +++++++ .../persistence/mapper/RecipeMapper.java | 46 ++++ .../repository/JpaRecipeRepository.java | 90 +++++++ .../repository/RecipeJpaRepository.java | 13 + .../web/controller/RecipeController.java | 77 ++++++ .../web/dto/CreateRecipeRequest.java | 16 ++ .../production/web/dto/RecipeResponse.java | 37 +++ .../ProductionErrorHttpStatusMapper.java | 19 ++ .../web/exception/GlobalExceptionHandler.java | 22 ++ .../changes/010-create-recipe-schema.xml | 63 +++++ .../db/changelog/db.changelog-master.xml | 1 + .../domain/production/RecipeTest.java | 230 ++++++++++++++++++ 23 files changed, 1095 insertions(+) create mode 100644 backend/src/main/java/de/effigenix/application/production/CreateRecipe.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/Recipe.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeError.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeId.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeName.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeStatus.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/RecipeType.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java create mode 100644 backend/src/main/resources/db/changelog/changes/010-create-recipe-schema.xml create mode 100644 backend/src/test/java/de/effigenix/domain/production/RecipeTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java b/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java new file mode 100644 index 0000000..592530d --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java @@ -0,0 +1,50 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.CreateRecipeCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class CreateRecipe { + + private final RecipeRepository recipeRepository; + + public CreateRecipe(RecipeRepository recipeRepository) { + this.recipeRepository = recipeRepository; + } + + public Result execute(CreateRecipeCommand cmd, ActorId performedBy) { + var draft = new RecipeDraft( + cmd.name(), cmd.version(), cmd.type(), cmd.description(), + cmd.yieldPercentage(), cmd.shelfLifeDays(), + cmd.outputQuantity(), cmd.outputUom() + ); + + Recipe recipe; + switch (Recipe.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> recipe = val; + } + + // Uniqueness check: Name + Version + switch (recipeRepository.existsByNameAndVersion(cmd.name(), cmd.version())) { + case Result.Failure(var err) -> + { return Result.failure(new RecipeError.RepositoryFailure(err.message())); } + case Result.Success(var exists) -> { + if (exists) { + return Result.failure(new RecipeError.NameAndVersionAlreadyExists(cmd.name(), cmd.version())); + } + } + } + + switch (recipeRepository.save(recipe)) { + case Result.Failure(var err) -> + { return Result.failure(new RecipeError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(recipe); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java b/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java new file mode 100644 index 0000000..d3d4a76 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java @@ -0,0 +1,14 @@ +package de.effigenix.application.production.command; + +import de.effigenix.domain.production.RecipeType; + +public record CreateRecipeCommand( + String name, + int version, + RecipeType type, + String description, + int yieldPercentage, + Integer shelfLifeDays, + String outputQuantity, + String outputUom +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/Recipe.java b/backend/src/main/java/de/effigenix/domain/production/Recipe.java new file mode 100644 index 0000000..45db083 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/Recipe.java @@ -0,0 +1,172 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +import static de.effigenix.shared.common.Result.*; + +/** + * Recipe aggregate root. + * + * Invariants: + * 1. Name must not be blank + * 2. Version >= 1 + * 3. YieldPercentage 1-200% + * 4. ShelfLifeDays > 0 for FINISHED_PRODUCT and INTERMEDIATE + * 5. OutputQuantity must be positive + * 6. New recipes always start in DRAFT status + */ +public class Recipe { + + private final RecipeId id; + private RecipeName name; + private int version; + private RecipeType type; + private String description; + private YieldPercentage yieldPercentage; + private Integer shelfLifeDays; + private Quantity outputQuantity; + private RecipeStatus status; + private final LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private Recipe( + RecipeId id, + RecipeName name, + int version, + RecipeType type, + String description, + YieldPercentage yieldPercentage, + Integer shelfLifeDays, + Quantity outputQuantity, + RecipeStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + this.id = id; + this.name = name; + this.version = version; + this.type = type; + this.description = description; + this.yieldPercentage = yieldPercentage; + this.shelfLifeDays = shelfLifeDays; + this.outputQuantity = outputQuantity; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** + * Factory: Creates a new Recipe in DRAFT status from raw inputs. + * Orchestrates VO validation internally. + */ + public static Result create(RecipeDraft draft) { + // 1. Name (required) + RecipeName name; + switch (RecipeName.of(draft.name())) { + case Failure(var msg) -> { return Result.failure(new RecipeError.ValidationFailure(msg)); } + case Success(var val) -> name = val; + } + + // 2. Version >= 1 + if (draft.version() < 1) { + return Result.failure(new RecipeError.ValidationFailure("Version must be >= 1, was: " + draft.version())); + } + + // 3. YieldPercentage (1-200) + YieldPercentage yieldPercentage; + switch (YieldPercentage.of(draft.yieldPercentage())) { + case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); } + case Success(var val) -> yieldPercentage = val; + } + + // 4. ShelfLifeDays: required and > 0 for FINISHED_PRODUCT and INTERMEDIATE + Integer shelfLifeDays = draft.shelfLifeDays(); + if (draft.type() == RecipeType.FINISHED_PRODUCT || draft.type() == RecipeType.INTERMEDIATE) { + if (shelfLifeDays == null || shelfLifeDays <= 0) { + return Result.failure(new RecipeError.InvalidShelfLife( + "ShelfLifeDays must be > 0 for " + draft.type() + ", was: " + shelfLifeDays)); + } + } + + // 5. Output Quantity (required, positive) + Quantity outputQuantity; + try { + BigDecimal amount = new BigDecimal(draft.outputQuantity()); + UnitOfMeasure uom = UnitOfMeasure.valueOf(draft.outputUom()); + switch (Quantity.of(amount, uom)) { + case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); } + case Success(var val) -> outputQuantity = val; + } + } catch (IllegalArgumentException e) { + return Result.failure(new RecipeError.ValidationFailure("Invalid output quantity: " + e.getMessage())); + } + + var now = LocalDateTime.now(); + return Result.success(new Recipe( + RecipeId.generate(), name, draft.version(), draft.type(), + draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, + RecipeStatus.DRAFT, now, now + )); + } + + /** + * Reconstitutes a Recipe from persistence. No validation. + */ + public static Recipe reconstitute( + RecipeId id, + RecipeName name, + int version, + RecipeType type, + String description, + YieldPercentage yieldPercentage, + Integer shelfLifeDays, + Quantity outputQuantity, + RecipeStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + return new Recipe(id, name, version, type, description, + yieldPercentage, shelfLifeDays, outputQuantity, status, createdAt, updatedAt); + } + + // ==================== Getters ==================== + + public RecipeId id() { return id; } + public RecipeName name() { return name; } + public int version() { return version; } + public RecipeType type() { return type; } + public String description() { return description; } + public YieldPercentage yieldPercentage() { return yieldPercentage; } + public Integer shelfLifeDays() { return shelfLifeDays; } + public Quantity outputQuantity() { return outputQuantity; } + public RecipeStatus status() { return status; } + public LocalDateTime createdAt() { return createdAt; } + public LocalDateTime updatedAt() { return updatedAt; } + + // ==================== Helpers ==================== + + private void touch() { + this.updatedAt = LocalDateTime.now(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Recipe other)) return false; + return id.equals(other.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return "Recipe{id=" + id + ", name=" + name + ", version=" + version + ", status=" + status + "}"; + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java b/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java new file mode 100644 index 0000000..5f72287 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java @@ -0,0 +1,25 @@ +package de.effigenix.domain.production; + +/** + * Draft for creating a new Recipe. Application layer builds this from raw command inputs. + * The Recipe aggregate validates and constructs VOs internally. + * + * @param name Recipe name (required) + * @param version Version number (required, >= 1) + * @param type Recipe type (required) + * @param description Free-text description (optional) + * @param yieldPercentage Yield percentage 1-200 (required) + * @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE) + * @param outputQuantity Expected output quantity amount (required) + * @param outputUom Expected output unit of measure (required) + */ +public record RecipeDraft( + String name, + int version, + RecipeType type, + String description, + int yieldPercentage, + Integer shelfLifeDays, + String outputQuantity, + String outputUom +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeError.java b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java new file mode 100644 index 0000000..0b4bd45 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java @@ -0,0 +1,34 @@ +package de.effigenix.domain.production; + +public sealed interface RecipeError { + + String code(); + String message(); + + record RecipeNotFound(RecipeId id) implements RecipeError { + @Override public String code() { return "RECIPE_NOT_FOUND"; } + @Override public String message() { return "Recipe with ID '" + id.value() + "' not found"; } + } + + record NameAndVersionAlreadyExists(String name, int version) implements RecipeError { + @Override public String code() { return "RECIPE_NAME_VERSION_EXISTS"; } + @Override public String message() { return "Recipe '" + name + "' version " + version + " already exists"; } + } + + record InvalidShelfLife(String reason) implements RecipeError { + @Override public String code() { return "RECIPE_INVALID_SHELF_LIFE"; } + @Override public String message() { return "Invalid shelf life: " + reason; } + } + + record ValidationFailure(String message) implements RecipeError { + @Override public String code() { return "RECIPE_VALIDATION_ERROR"; } + } + + record Unauthorized(String message) implements RecipeError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements RecipeError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeId.java b/backend/src/main/java/de/effigenix/domain/production/RecipeId.java new file mode 100644 index 0000000..f89a6fc --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.production; + +import java.util.UUID; + +public record RecipeId(String value) { + + public RecipeId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("RecipeId must not be blank"); + } + } + + public static RecipeId generate() { + return new RecipeId(UUID.randomUUID().toString()); + } + + public static RecipeId of(String value) { + return new RecipeId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeName.java b/backend/src/main/java/de/effigenix/domain/production/RecipeName.java new file mode 100644 index 0000000..33cd6df --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeName.java @@ -0,0 +1,23 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +public record RecipeName(String value) { + + public RecipeName { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("RecipeName must not be blank"); + } + if (value.length() > 200) { + throw new IllegalArgumentException("RecipeName must not exceed 200 characters"); + } + } + + public static Result of(String value) { + try { + return Result.success(new RecipeName(value)); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java new file mode 100644 index 0000000..a8c3d9a --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeRepository.java @@ -0,0 +1,20 @@ +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 RecipeRepository { + + Result> findById(RecipeId id); + + Result> findAll(); + + Result save(Recipe recipe); + + Result delete(Recipe recipe); + + Result existsByNameAndVersion(String name, int version); +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeStatus.java b/backend/src/main/java/de/effigenix/domain/production/RecipeStatus.java new file mode 100644 index 0000000..6392087 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeStatus.java @@ -0,0 +1,7 @@ +package de.effigenix.domain.production; + +public enum RecipeStatus { + DRAFT, + ACTIVE, + ARCHIVED +} diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeType.java b/backend/src/main/java/de/effigenix/domain/production/RecipeType.java new file mode 100644 index 0000000..5fb0bc5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeType.java @@ -0,0 +1,7 @@ +package de.effigenix.domain.production; + +public enum RecipeType { + RAW_MATERIAL, + INTERMEDIATE, + FINISHED_PRODUCT +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java new file mode 100644 index 0000000..037bcf3 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -0,0 +1,15 @@ +package de.effigenix.infrastructure.config; + +import de.effigenix.application.production.CreateRecipe; +import de.effigenix.domain.production.RecipeRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ProductionUseCaseConfiguration { + + @Bean + public CreateRecipe createRecipe(RecipeRepository recipeRepository) { + return new CreateRecipe(recipeRepository); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java new file mode 100644 index 0000000..993949e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java @@ -0,0 +1,94 @@ +package de.effigenix.infrastructure.production.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "recipes", + uniqueConstraints = @UniqueConstraint(name = "uq_recipe_name_version", columnNames = {"name", "version"})) +public class RecipeEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "type", nullable = false, length = 30) + private String type; + + @Column(name = "description", length = 2000) + private String description; + + @Column(name = "yield_percentage", nullable = false) + private int yieldPercentage; + + @Column(name = "shelf_life_days") + private Integer shelfLifeDays; + + @Column(name = "output_quantity", nullable = false, precision = 19, scale = 6) + private BigDecimal outputQuantity; + + @Column(name = "output_uom", nullable = false, length = 20) + private String outputUom; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected RecipeEntity() {} + + public RecipeEntity(String id, String name, int version, String type, String description, + int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity, + String outputUom, String status, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.name = name; + this.version = version; + this.type = type; + this.description = description; + this.yieldPercentage = yieldPercentage; + this.shelfLifeDays = shelfLifeDays; + this.outputQuantity = outputQuantity; + this.outputUom = outputUom; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getId() { return id; } + public String getName() { return name; } + public int getVersion() { return version; } + public String getType() { return type; } + public String getDescription() { return description; } + public int getYieldPercentage() { return yieldPercentage; } + public Integer getShelfLifeDays() { return shelfLifeDays; } + public BigDecimal getOutputQuantity() { return outputQuantity; } + public String getOutputUom() { return outputUom; } + public String getStatus() { return status; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + + public void setId(String id) { this.id = id; } + public void setName(String name) { this.name = name; } + public void setVersion(int version) { this.version = version; } + public void setType(String type) { this.type = type; } + public void setDescription(String description) { this.description = description; } + public void setYieldPercentage(int yieldPercentage) { this.yieldPercentage = yieldPercentage; } + public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; } + public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; } + public void setOutputUom(String outputUom) { this.outputUom = outputUom; } + public void setStatus(String status) { this.status = status; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java new file mode 100644 index 0000000..5df15ff --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java @@ -0,0 +1,46 @@ +package de.effigenix.infrastructure.production.persistence.mapper; + +import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; +import org.springframework.stereotype.Component; + +@Component +public class RecipeMapper { + + public RecipeEntity toEntity(Recipe recipe) { + return new RecipeEntity( + recipe.id().value(), + recipe.name().value(), + recipe.version(), + recipe.type().name(), + recipe.description(), + recipe.yieldPercentage().value(), + recipe.shelfLifeDays(), + recipe.outputQuantity().amount(), + recipe.outputQuantity().uom().name(), + recipe.status().name(), + recipe.createdAt(), + recipe.updatedAt() + ); + } + + public Recipe toDomain(RecipeEntity entity) { + return Recipe.reconstitute( + RecipeId.of(entity.getId()), + new RecipeName(entity.getName()), + entity.getVersion(), + RecipeType.valueOf(entity.getType()), + entity.getDescription(), + new YieldPercentage(entity.getYieldPercentage()), + entity.getShelfLifeDays(), + Quantity.reconstitute( + entity.getOutputQuantity(), + UnitOfMeasure.valueOf(entity.getOutputUom()), + null, null + ), + RecipeStatus.valueOf(entity.getStatus()), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java new file mode 100644 index 0000000..33850b9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaRecipeRepository.java @@ -0,0 +1,90 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.mapper.RecipeMapper; +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 JpaRecipeRepository implements RecipeRepository { + + private static final Logger logger = LoggerFactory.getLogger(JpaRecipeRepository.class); + + private final RecipeJpaRepository jpaRepository; + private final RecipeMapper mapper; + + public JpaRecipeRepository(RecipeJpaRepository jpaRepository, RecipeMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Result> findById(RecipeId 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(Recipe recipe) { + try { + jpaRepository.save(mapper.toEntity(recipe)); + return Result.success(null); + } catch (Exception e) { + logger.trace("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + @Transactional + public Result delete(Recipe recipe) { + try { + jpaRepository.deleteById(recipe.id().value()); + return Result.success(null); + } catch (Exception e) { + logger.trace("Database error in delete", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result existsByNameAndVersion(String name, int version) { + try { + return Result.success(jpaRepository.existsByNameAndVersion(name, version)); + } catch (Exception e) { + logger.trace("Database error in existsByNameAndVersion", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java new file mode 100644 index 0000000..5110d7d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/RecipeJpaRepository.java @@ -0,0 +1,13 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RecipeJpaRepository extends JpaRepository { + + List findByStatus(String status); + + boolean existsByNameAndVersion(String name, int version); +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java new file mode 100644 index 0000000..eb6eb7f --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java @@ -0,0 +1,77 @@ +package de.effigenix.infrastructure.production.web.controller; + +import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.command.CreateRecipeCommand; +import de.effigenix.domain.production.RecipeError; +import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest; +import de.effigenix.infrastructure.production.web.dto.RecipeResponse; +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/recipes") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Recipes", description = "Recipe management endpoints") +public class RecipeController { + + private static final Logger logger = LoggerFactory.getLogger(RecipeController.class); + + private final CreateRecipe createRecipe; + + public RecipeController(CreateRecipe createRecipe) { + this.createRecipe = createRecipe; + } + + @PostMapping + @PreAuthorize("hasAuthority('RECIPE_WRITE')") + public ResponseEntity createRecipe( + @Valid @RequestBody CreateRecipeRequest request, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + logger.info("Creating recipe: {} v{} by actor: {}", request.name(), request.version(), actorId.value()); + + var cmd = new CreateRecipeCommand( + request.name(), request.version(), request.type(), request.description(), + request.yieldPercentage(), request.shelfLifeDays(), + request.outputQuantity(), request.outputUom() + ); + var result = createRecipe.execute(cmd, actorId); + + if (result.isFailure()) { + throw new RecipeDomainErrorException(result.unsafeGetError()); + } + + logger.info("Recipe created: {} v{}", request.name(), request.version()); + return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue())); + } + + private ActorId extractActorId(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + throw new IllegalStateException("No authentication found in SecurityContext"); + } + return ActorId.of(authentication.getName()); + } + + public static class RecipeDomainErrorException extends RuntimeException { + private final RecipeError error; + + public RecipeDomainErrorException(RecipeError error) { + super(error.message()); + this.error = error; + } + + public RecipeError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java new file mode 100644 index 0000000..2077f51 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java @@ -0,0 +1,16 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.RecipeType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateRecipeRequest( + @NotBlank String name, + int version, + @NotNull RecipeType type, + String description, + int yieldPercentage, + Integer shelfLifeDays, + @NotBlank String outputQuantity, + @NotBlank String outputUom +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java new file mode 100644 index 0000000..d450c11 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java @@ -0,0 +1,37 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.Recipe; + +import java.time.LocalDateTime; + +public record RecipeResponse( + String id, + String name, + int version, + String type, + String description, + int yieldPercentage, + Integer shelfLifeDays, + String outputQuantity, + String outputUom, + String status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static RecipeResponse from(Recipe recipe) { + return new RecipeResponse( + recipe.id().value(), + recipe.name().value(), + recipe.version(), + recipe.type().name(), + recipe.description(), + recipe.yieldPercentage().value(), + recipe.shelfLifeDays(), + recipe.outputQuantity().amount().toPlainString(), + recipe.outputQuantity().uom().name(), + recipe.status().name(), + recipe.createdAt(), + recipe.updatedAt() + ); + } +} 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 new file mode 100644 index 0000000..808a731 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -0,0 +1,19 @@ +package de.effigenix.infrastructure.production.web.exception; + +import de.effigenix.domain.production.RecipeError; + +public final class ProductionErrorHttpStatusMapper { + + private ProductionErrorHttpStatusMapper() {} + + public static int toHttpStatus(RecipeError error) { + return switch (error) { + case RecipeError.RecipeNotFound e -> 404; + case RecipeError.NameAndVersionAlreadyExists e -> 409; + case RecipeError.InvalidShelfLife e -> 400; + case RecipeError.ValidationFailure e -> 400; + case RecipeError.Unauthorized e -> 403; + case RecipeError.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 a768cc3..2c2bfa6 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 @@ -5,6 +5,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.RecipeError; import de.effigenix.domain.usermanagement.UserError; import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper; @@ -12,6 +13,8 @@ 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.RecipeController; +import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper; import de.effigenix.shared.common.RepositoryError; import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper; import de.effigenix.infrastructure.usermanagement.web.controller.AuthController; @@ -201,6 +204,25 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(RecipeController.RecipeDomainErrorException.class) + public ResponseEntity handleRecipeDomainError( + RecipeController.RecipeDomainErrorException ex, + HttpServletRequest request + ) { + RecipeError error = ex.getError(); + int status = ProductionErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("Recipe", error.code(), error.message(), status); + + ErrorResponse errorResponse = ErrorResponse.from( + error.code(), + error.message(), + 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/010-create-recipe-schema.xml b/backend/src/main/resources/db/changelog/changes/010-create-recipe-schema.xml new file mode 100644 index 0000000..31f0d6e --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/010-create-recipe-schema.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE recipes ADD CONSTRAINT chk_recipe_type CHECK (type IN ('RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT')); + ALTER TABLE recipes ADD CONSTRAINT chk_recipe_status CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')); + ALTER TABLE recipes ADD CONSTRAINT chk_recipe_yield CHECK (yield_percentage BETWEEN 1 AND 200); + + + + + + + + + + + + + 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 9d73e08..9a8ec40 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -14,5 +14,6 @@ + diff --git a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java new file mode 100644 index 0000000..8c91b36 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java @@ -0,0 +1,230 @@ +package de.effigenix.domain.production; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Recipe Aggregate") +class RecipeTest { + + private RecipeDraft validDraft() { + return new RecipeDraft( + "Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT, + "Grobe Bratwurst nach Hausrezept", 85, + 14, "100", "KILOGRAM" + ); + } + + @Nested + @DisplayName("create()") + class Create { + + @Test + @DisplayName("should create recipe in DRAFT status with valid inputs") + void should_CreateInDraftStatus_When_ValidInputs() { + var result = Recipe.create(validDraft()); + + assertThat(result.isSuccess()).isTrue(); + var recipe = result.unsafeGetValue(); + assertThat(recipe.id()).isNotNull(); + assertThat(recipe.name().value()).isEqualTo("Bratwurst Grob"); + assertThat(recipe.version()).isEqualTo(1); + assertThat(recipe.type()).isEqualTo(RecipeType.FINISHED_PRODUCT); + assertThat(recipe.description()).isEqualTo("Grobe Bratwurst nach Hausrezept"); + assertThat(recipe.yieldPercentage().value()).isEqualTo(85); + assertThat(recipe.shelfLifeDays()).isEqualTo(14); + assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100"); + assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT); + assertThat(recipe.createdAt()).isNotNull(); + assertThat(recipe.updatedAt()).isNotNull(); + } + + @Test + @DisplayName("should fail when name is blank") + void should_Fail_When_NameIsBlank() { + var draft = new RecipeDraft( + "", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when name is null") + void should_Fail_When_NameIsNull() { + var draft = new RecipeDraft( + null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when version is less than 1") + void should_Fail_When_VersionLessThanOne() { + var draft = new RecipeDraft( + "Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when yield percentage is 0") + void should_Fail_When_YieldPercentageIsZero() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when yield percentage is 201") + void should_Fail_When_YieldPercentageIs201() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT") + void should_Fail_When_ShelfLifeZeroForFinishedProduct() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class); + } + + @Test + @DisplayName("should fail when shelf life is null for FINISHED_PRODUCT") + void should_Fail_When_ShelfLifeNullForFinishedProduct() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class); + } + + @Test + @DisplayName("should fail when shelf life is 0 for INTERMEDIATE") + void should_Fail_When_ShelfLifeZeroForIntermediate() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class); + } + + @Test + @DisplayName("should allow null shelf life for RAW_MATERIAL") + void should_AllowNullShelfLife_When_RawMaterial() { + var draft = new RecipeDraft( + "Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().shelfLifeDays()).isNull(); + } + + @Test + @DisplayName("should fail when output quantity is zero") + void should_Fail_When_OutputQuantityZero() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when output UoM is invalid") + void should_Fail_When_OutputUomInvalid() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID" + ); + + var result = Recipe.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should allow description to be null") + void should_AllowNullDescription() { + var draft = new RecipeDraft( + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + ); + + var result = Recipe.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().description()).isNull(); + } + } + + @Nested + @DisplayName("Equality") + class Equality { + + @Test + @DisplayName("should be equal when same ID") + void should_BeEqual_When_SameId() { + var recipe1 = Recipe.create(validDraft()).unsafeGetValue(); + var recipe2 = Recipe.reconstitute( + recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL, + null, new YieldPercentage(100), null, + Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, recipe1.createdAt(), recipe1.updatedAt() + ); + + assertThat(recipe1).isEqualTo(recipe2); + assertThat(recipe1.hashCode()).isEqualTo(recipe2.hashCode()); + } + + @Test + @DisplayName("should not be equal when different ID") + void should_NotBeEqual_When_DifferentId() { + var recipe1 = Recipe.create(validDraft()).unsafeGetValue(); + var recipe2 = Recipe.create(validDraft()).unsafeGetValue(); + + assertThat(recipe1).isNotEqualTo(recipe2); + } + } +}