diff --git a/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java b/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java new file mode 100644 index 0000000..3b2876e --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java @@ -0,0 +1,51 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.AddRecipeIngredientCommand; +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 AddRecipeIngredient { + + private final RecipeRepository recipeRepository; + + public AddRecipeIngredient(RecipeRepository recipeRepository) { + this.recipeRepository = recipeRepository; + } + + public Result execute(AddRecipeIngredientCommand cmd, ActorId performedBy) { + var recipeId = RecipeId.of(cmd.recipeId()); + + Recipe recipe; + switch (recipeRepository.findById(recipeId)) { + case Result.Failure(var err) -> + { return Result.failure(new RecipeError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new RecipeError.RecipeNotFound(recipeId)); + } + recipe = opt.get(); + } + } + + var draft = new IngredientDraft( + cmd.position(), cmd.articleId(), cmd.quantity(), + cmd.uom(), cmd.subRecipeId(), cmd.substitutable() + ); + + switch (recipe.addIngredient(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + 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/RemoveRecipeIngredient.java b/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java new file mode 100644 index 0000000..e3773ed --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java @@ -0,0 +1,46 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.RemoveRecipeIngredientCommand; +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 RemoveRecipeIngredient { + + private final RecipeRepository recipeRepository; + + public RemoveRecipeIngredient(RecipeRepository recipeRepository) { + this.recipeRepository = recipeRepository; + } + + public Result execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) { + var recipeId = RecipeId.of(cmd.recipeId()); + + Recipe recipe; + switch (recipeRepository.findById(recipeId)) { + case Result.Failure(var err) -> + { return Result.failure(new RecipeError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new RecipeError.RecipeNotFound(recipeId)); + } + recipe = opt.get(); + } + } + + switch (recipe.removeIngredient(IngredientId.of(cmd.ingredientId()))) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + 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/AddRecipeIngredientCommand.java b/backend/src/main/java/de/effigenix/application/production/command/AddRecipeIngredientCommand.java new file mode 100644 index 0000000..5c5969a --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/AddRecipeIngredientCommand.java @@ -0,0 +1,11 @@ +package de.effigenix.application.production.command; + +public record AddRecipeIngredientCommand( + String recipeId, + int position, + String articleId, + String quantity, + String uom, + String subRecipeId, + boolean substitutable +) {} diff --git a/backend/src/main/java/de/effigenix/application/production/command/RemoveRecipeIngredientCommand.java b/backend/src/main/java/de/effigenix/application/production/command/RemoveRecipeIngredientCommand.java new file mode 100644 index 0000000..adb3f05 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/RemoveRecipeIngredientCommand.java @@ -0,0 +1,6 @@ +package de.effigenix.application.production.command; + +public record RemoveRecipeIngredientCommand( + String recipeId, + String ingredientId +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/Ingredient.java b/backend/src/main/java/de/effigenix/domain/production/Ingredient.java new file mode 100644 index 0000000..c6fb478 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/Ingredient.java @@ -0,0 +1,92 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; + +/** + * Child entity of Recipe representing a single ingredient. + * + * Invariants: + * 1. Position must be > 0 + * 2. ArticleId must not be blank + * 3. Quantity must be positive + */ +public class Ingredient { + + private final IngredientId id; + private final int position; + private final String articleId; + private final Quantity quantity; + private final String subRecipeId; + private final boolean substitutable; + + private Ingredient(IngredientId id, int position, String articleId, + Quantity quantity, String subRecipeId, boolean substitutable) { + this.id = id; + this.position = position; + this.articleId = articleId; + this.quantity = quantity; + this.subRecipeId = subRecipeId; + this.substitutable = substitutable; + } + + public static Result create(IngredientDraft draft) { + if (draft.position() <= 0) { + return Result.failure(new RecipeError.ValidationFailure( + "Ingredient position must be > 0, was: " + draft.position())); + } + + if (draft.articleId() == null || draft.articleId().isBlank()) { + return Result.failure(new RecipeError.ValidationFailure("Ingredient articleId must not be blank")); + } + + Quantity quantity; + try { + BigDecimal amount = new BigDecimal(draft.quantity()); + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(draft.uom()); + } catch (IllegalArgumentException e) { + return Result.failure(new RecipeError.ValidationFailure("Invalid unit of measure: " + draft.uom())); + } + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new RecipeError.ValidationFailure(err.message())); + } + case Result.Success(var val) -> quantity = val; + } + } catch (NumberFormatException e) { + return Result.failure(new RecipeError.ValidationFailure("Invalid ingredient quantity: " + e.getMessage())); + } + + return Result.success(new Ingredient( + IngredientId.generate(), draft.position(), draft.articleId(), + quantity, draft.subRecipeId(), draft.substitutable() + )); + } + + public static Ingredient reconstitute(IngredientId id, int position, String articleId, + Quantity quantity, String subRecipeId, boolean substitutable) { + return new Ingredient(id, position, articleId, quantity, subRecipeId, substitutable); + } + + public IngredientId id() { return id; } + public int position() { return position; } + public String articleId() { return articleId; } + public Quantity quantity() { return quantity; } + public String subRecipeId() { return subRecipeId; } + public boolean substitutable() { return substitutable; } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Ingredient other)) return false; + return id.equals(other.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/IngredientDraft.java b/backend/src/main/java/de/effigenix/domain/production/IngredientDraft.java new file mode 100644 index 0000000..8a75aec --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/IngredientDraft.java @@ -0,0 +1,26 @@ +package de.effigenix.domain.production; + +/** + * Draft for adding an ingredient to a recipe. Application layer builds this from raw command inputs. + * The Ingredient entity validates and constructs VOs internally. + * + * @param position Position in the recipe (required, > 0) + * @param articleId Reference to the article (required) + * @param quantity Quantity amount as string (required) + * @param uom Unit of measure as string (required) + * @param subRecipeId Optional reference to a sub-recipe + * @param substitutable Whether this ingredient can be substituted + */ +public record IngredientDraft( + int position, + String articleId, + String quantity, + String uom, + String subRecipeId, + boolean substitutable +) { + public IngredientDraft { + if (quantity == null) throw new IllegalArgumentException("quantity must not be null"); + if (uom == null) throw new IllegalArgumentException("uom must not be null"); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/IngredientId.java b/backend/src/main/java/de/effigenix/domain/production/IngredientId.java new file mode 100644 index 0000000..bde023f --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/IngredientId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.production; + +import java.util.UUID; + +public record IngredientId(String value) { + + public IngredientId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("IngredientId must not be blank"); + } + } + + public static IngredientId generate() { + return new IngredientId(UUID.randomUUID().toString()); + } + + public static IngredientId of(String value) { + return new IngredientId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/Recipe.java b/backend/src/main/java/de/effigenix/domain/production/Recipe.java index 45db083..db7a268 100644 --- a/backend/src/main/java/de/effigenix/domain/production/Recipe.java +++ b/backend/src/main/java/de/effigenix/domain/production/Recipe.java @@ -4,6 +4,9 @@ import de.effigenix.shared.common.Result; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Objects; import static de.effigenix.shared.common.Result.*; @@ -18,6 +21,8 @@ import static de.effigenix.shared.common.Result.*; * 4. ShelfLifeDays > 0 for FINISHED_PRODUCT and INTERMEDIATE * 5. OutputQuantity must be positive * 6. New recipes always start in DRAFT status + * 7. Ingredients can only be added/removed in DRAFT status + * 8. Ingredient positions must be unique within a recipe */ public class Recipe { @@ -30,6 +35,7 @@ public class Recipe { private Integer shelfLifeDays; private Quantity outputQuantity; private RecipeStatus status; + private final List ingredients; private final LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -43,6 +49,7 @@ public class Recipe { Integer shelfLifeDays, Quantity outputQuantity, RecipeStatus status, + List ingredients, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -55,6 +62,7 @@ public class Recipe { this.shelfLifeDays = shelfLifeDays; this.outputQuantity = outputQuantity; this.status = status; + this.ingredients = new ArrayList<>(ingredients); this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -109,7 +117,7 @@ public class Recipe { return Result.success(new Recipe( RecipeId.generate(), name, draft.version(), draft.type(), draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, - RecipeStatus.DRAFT, now, now + RecipeStatus.DRAFT, List.of(), now, now )); } @@ -126,11 +134,45 @@ public class Recipe { Integer shelfLifeDays, Quantity outputQuantity, RecipeStatus status, + List ingredients, LocalDateTime createdAt, LocalDateTime updatedAt ) { return new Recipe(id, name, version, type, description, - yieldPercentage, shelfLifeDays, outputQuantity, status, createdAt, updatedAt); + yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, createdAt, updatedAt); + } + + // ==================== Ingredient Management ==================== + + public Result addIngredient(IngredientDraft draft) { + if (status != RecipeStatus.DRAFT) { + return Result.failure(new RecipeError.NotInDraftStatus()); + } + if (hasPosition(draft.position())) { + return Result.failure(new RecipeError.DuplicatePosition(draft.position())); + } + + Ingredient ingredient; + switch (Ingredient.create(draft)) { + case Failure(var err) -> { return Result.failure(err); } + case Success(var val) -> ingredient = val; + } + + this.ingredients.add(ingredient); + touch(); + return Result.success(ingredient); + } + + public Result removeIngredient(IngredientId ingredientId) { + if (status != RecipeStatus.DRAFT) { + return Result.failure(new RecipeError.NotInDraftStatus()); + } + boolean removed = this.ingredients.removeIf(i -> i.id().equals(ingredientId)); + if (!removed) { + return Result.failure(new RecipeError.IngredientNotFound(ingredientId)); + } + touch(); + return Result.success(null); } // ==================== Getters ==================== @@ -144,11 +186,16 @@ public class Recipe { public Integer shelfLifeDays() { return shelfLifeDays; } public Quantity outputQuantity() { return outputQuantity; } public RecipeStatus status() { return status; } + public List ingredients() { return Collections.unmodifiableList(ingredients); } public LocalDateTime createdAt() { return createdAt; } public LocalDateTime updatedAt() { return updatedAt; } // ==================== Helpers ==================== + private boolean hasPosition(int position) { + return ingredients.stream().anyMatch(i -> i.position() == position); + } + private void touch() { this.updatedAt = LocalDateTime.now(); } diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeError.java b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java index 0b4bd45..dca6022 100644 --- a/backend/src/main/java/de/effigenix/domain/production/RecipeError.java +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java @@ -24,6 +24,21 @@ public sealed interface RecipeError { @Override public String code() { return "RECIPE_VALIDATION_ERROR"; } } + record NotInDraftStatus() implements RecipeError { + @Override public String code() { return "RECIPE_NOT_IN_DRAFT"; } + @Override public String message() { return "Recipe can only be modified in DRAFT status"; } + } + + record DuplicatePosition(int position) implements RecipeError { + @Override public String code() { return "RECIPE_DUPLICATE_POSITION"; } + @Override public String message() { return "Ingredient position " + position + " is already taken"; } + } + + record IngredientNotFound(IngredientId id) implements RecipeError { + @Override public String code() { return "INGREDIENT_NOT_FOUND"; } + @Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; } + } + record Unauthorized(String message) implements RecipeError { @Override public String code() { return "UNAUTHORIZED"; } } 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 037bcf3..ab5d5fb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -1,6 +1,8 @@ package de.effigenix.infrastructure.config; +import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.RemoveRecipeIngredient; import de.effigenix.domain.production.RecipeRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,4 +14,14 @@ public class ProductionUseCaseConfiguration { public CreateRecipe createRecipe(RecipeRepository recipeRepository) { return new CreateRecipe(recipeRepository); } + + @Bean + public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) { + return new AddRecipeIngredient(recipeRepository); + } + + @Bean + public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) { + return new RemoveRecipeIngredient(recipeRepository); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/IngredientEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/IngredientEntity.java new file mode 100644 index 0000000..1aa6691 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/IngredientEntity.java @@ -0,0 +1,68 @@ +package de.effigenix.infrastructure.production.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; + +@Entity +@Table(name = "recipe_ingredients") +public class IngredientEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id", nullable = false) + private RecipeEntity recipe; + + @Column(name = "position", nullable = false) + private int position; + + @Column(name = "article_id", nullable = false, length = 36) + private String articleId; + + @Column(name = "quantity", nullable = false, precision = 19, scale = 6) + private BigDecimal quantity; + + @Column(name = "uom", nullable = false, length = 20) + private String uom; + + @Column(name = "sub_recipe_id", length = 36) + private String subRecipeId; + + @Column(name = "substitutable", nullable = false) + private boolean substitutable; + + protected IngredientEntity() {} + + public IngredientEntity(String id, RecipeEntity recipe, int position, String articleId, + BigDecimal quantity, String uom, String subRecipeId, boolean substitutable) { + this.id = id; + this.recipe = recipe; + this.position = position; + this.articleId = articleId; + this.quantity = quantity; + this.uom = uom; + this.subRecipeId = subRecipeId; + this.substitutable = substitutable; + } + + public String getId() { return id; } + public RecipeEntity getRecipe() { return recipe; } + public int getPosition() { return position; } + public String getArticleId() { return articleId; } + public BigDecimal getQuantity() { return quantity; } + public String getUom() { return uom; } + public String getSubRecipeId() { return subRecipeId; } + public boolean isSubstitutable() { return substitutable; } + + public void setId(String id) { this.id = id; } + public void setRecipe(RecipeEntity recipe) { this.recipe = recipe; } + public void setPosition(int position) { this.position = position; } + public void setArticleId(String articleId) { this.articleId = articleId; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public void setUom(String uom) { this.uom = uom; } + public void setSubRecipeId(String subRecipeId) { this.subRecipeId = subRecipeId; } + public void setSubstitutable(boolean substitutable) { this.substitutable = substitutable; } +} 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 index 993949e..4d8481e 100644 --- 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 @@ -4,6 +4,8 @@ import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "recipes", @@ -47,6 +49,10 @@ public class RecipeEntity { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @OrderBy("position ASC") + private List ingredients = new ArrayList<>(); + protected RecipeEntity() {} public RecipeEntity(String id, String name, int version, String type, String description, @@ -78,6 +84,7 @@ public class RecipeEntity { public String getStatus() { return status; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } + public List getIngredients() { return ingredients; } public void setId(String id) { this.id = id; } public void setName(String name) { this.name = name; } @@ -91,4 +98,5 @@ public class RecipeEntity { public void setStatus(String status) { this.status = status; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public void setIngredients(List ingredients) { this.ingredients = ingredients; } } 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 index 5df15ff..0c8fdd7 100644 --- 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 @@ -1,14 +1,18 @@ package de.effigenix.infrastructure.production.persistence.mapper; import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity; import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.stream.Collectors; + @Component public class RecipeMapper { public RecipeEntity toEntity(Recipe recipe) { - return new RecipeEntity( + var entity = new RecipeEntity( recipe.id().value(), recipe.name().value(), recipe.version(), @@ -22,9 +26,20 @@ public class RecipeMapper { recipe.createdAt(), recipe.updatedAt() ); + + List ingredientEntities = recipe.ingredients().stream() + .map(i -> toIngredientEntity(i, entity)) + .collect(Collectors.toList()); + entity.setIngredients(ingredientEntities); + + return entity; } public Recipe toDomain(RecipeEntity entity) { + List ingredients = entity.getIngredients().stream() + .map(this::toDomainIngredient) + .collect(Collectors.toList()); + return Recipe.reconstitute( RecipeId.of(entity.getId()), new RecipeName(entity.getName()), @@ -39,8 +54,37 @@ public class RecipeMapper { null, null ), RecipeStatus.valueOf(entity.getStatus()), + ingredients, entity.getCreatedAt(), entity.getUpdatedAt() ); } + + private IngredientEntity toIngredientEntity(Ingredient ingredient, RecipeEntity recipe) { + return new IngredientEntity( + ingredient.id().value(), + recipe, + ingredient.position(), + ingredient.articleId(), + ingredient.quantity().amount(), + ingredient.quantity().uom().name(), + ingredient.subRecipeId(), + ingredient.substitutable() + ); + } + + private Ingredient toDomainIngredient(IngredientEntity entity) { + return Ingredient.reconstitute( + IngredientId.of(entity.getId()), + entity.getPosition(), + entity.getArticleId(), + Quantity.reconstitute( + entity.getQuantity(), + UnitOfMeasure.valueOf(entity.getUom()), + null, null + ), + entity.getSubRecipeId(), + entity.isSubstitutable() + ); + } } 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 index eb6eb7f..2a3f247 100644 --- 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 @@ -1,8 +1,13 @@ package de.effigenix.infrastructure.production.web.controller; +import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.RemoveRecipeIngredient; +import de.effigenix.application.production.command.AddRecipeIngredientCommand; import de.effigenix.application.production.command.CreateRecipeCommand; +import de.effigenix.application.production.command.RemoveRecipeIngredientCommand; import de.effigenix.domain.production.RecipeError; +import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest; import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest; import de.effigenix.infrastructure.production.web.dto.RecipeResponse; import de.effigenix.shared.security.ActorId; @@ -26,9 +31,15 @@ public class RecipeController { private static final Logger logger = LoggerFactory.getLogger(RecipeController.class); private final CreateRecipe createRecipe; + private final AddRecipeIngredient addRecipeIngredient; + private final RemoveRecipeIngredient removeRecipeIngredient; - public RecipeController(CreateRecipe createRecipe) { + public RecipeController(CreateRecipe createRecipe, + AddRecipeIngredient addRecipeIngredient, + RemoveRecipeIngredient removeRecipeIngredient) { this.createRecipe = createRecipe; + this.addRecipeIngredient = addRecipeIngredient; + this.removeRecipeIngredient = removeRecipeIngredient; } @PostMapping @@ -55,6 +66,51 @@ public class RecipeController { return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/ingredients") + @PreAuthorize("hasAuthority('RECIPE_WRITE')") + public ResponseEntity addIngredient( + @PathVariable("id") String recipeId, + @Valid @RequestBody AddRecipeIngredientRequest request, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + logger.info("Adding ingredient to recipe: {} by actor: {}", recipeId, actorId.value()); + + var cmd = new AddRecipeIngredientCommand( + recipeId, request.position(), request.articleId(), + request.quantity(), request.uom(), request.subRecipeId(), request.substitutable() + ); + var result = addRecipeIngredient.execute(cmd, actorId); + + if (result.isFailure()) { + throw new RecipeDomainErrorException(result.unsafeGetError()); + } + + logger.info("Ingredient added to recipe: {}", recipeId); + return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue())); + } + + @DeleteMapping("/{id}/ingredients/{ingredientId}") + @PreAuthorize("hasAuthority('RECIPE_WRITE')") + public ResponseEntity removeIngredient( + @PathVariable("id") String recipeId, + @PathVariable("ingredientId") String ingredientId, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + logger.info("Removing ingredient {} from recipe: {} by actor: {}", ingredientId, recipeId, actorId.value()); + + var cmd = new RemoveRecipeIngredientCommand(recipeId, ingredientId); + var result = removeRecipeIngredient.execute(cmd, actorId); + + if (result.isFailure()) { + throw new RecipeDomainErrorException(result.unsafeGetError()); + } + + logger.info("Ingredient removed from recipe: {}", recipeId); + return ResponseEntity.noContent().build(); + } + private ActorId extractActorId(Authentication authentication) { if (authentication == null || authentication.getName() == null) { throw new IllegalStateException("No authentication found in SecurityContext"); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddRecipeIngredientRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddRecipeIngredientRequest.java new file mode 100644 index 0000000..9d75778 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddRecipeIngredientRequest.java @@ -0,0 +1,13 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record AddRecipeIngredientRequest( + @Min(1) int position, + @NotBlank String articleId, + @NotBlank String quantity, + @NotBlank String uom, + String subRecipeId, + boolean substitutable +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/IngredientResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/IngredientResponse.java new file mode 100644 index 0000000..1f63d29 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/IngredientResponse.java @@ -0,0 +1,25 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.Ingredient; + +public record IngredientResponse( + String id, + int position, + String articleId, + String quantity, + String uom, + String subRecipeId, + boolean substitutable +) { + public static IngredientResponse from(Ingredient ingredient) { + return new IngredientResponse( + ingredient.id().value(), + ingredient.position(), + ingredient.articleId(), + ingredient.quantity().amount().toPlainString(), + ingredient.quantity().uom().name(), + ingredient.subRecipeId(), + ingredient.substitutable() + ); + } +} 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 index d450c11..7fcf588 100644 --- 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 @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.production.web.dto; import de.effigenix.domain.production.Recipe; import java.time.LocalDateTime; +import java.util.List; public record RecipeResponse( String id, @@ -15,6 +16,7 @@ public record RecipeResponse( String outputQuantity, String outputUom, String status, + List ingredients, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -30,6 +32,7 @@ public record RecipeResponse( recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().uom().name(), recipe.status().name(), + recipe.ingredients().stream().map(IngredientResponse::from).toList(), 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 index 808a731..ac929ce 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 @@ -9,9 +9,12 @@ public final class ProductionErrorHttpStatusMapper { public static int toHttpStatus(RecipeError error) { return switch (error) { case RecipeError.RecipeNotFound e -> 404; + case RecipeError.IngredientNotFound e -> 404; case RecipeError.NameAndVersionAlreadyExists e -> 409; + case RecipeError.DuplicatePosition e -> 409; case RecipeError.InvalidShelfLife e -> 400; case RecipeError.ValidationFailure e -> 400; + case RecipeError.NotInDraftStatus e -> 409; case RecipeError.Unauthorized e -> 403; case RecipeError.RepositoryFailure e -> 500; }; diff --git a/backend/src/main/resources/db/changelog/changes/011-create-recipe-ingredients-table.xml b/backend/src/main/resources/db/changelog/changes/011-create-recipe-ingredients-table.xml new file mode 100644 index 0000000..8552994 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/011-create-recipe-ingredients-table.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 9a8ec40..032102c 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -15,5 +15,6 @@ + diff --git a/backend/src/test/java/de/effigenix/domain/production/IngredientTest.java b/backend/src/test/java/de/effigenix/domain/production/IngredientTest.java new file mode 100644 index 0000000..a49e620 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/IngredientTest.java @@ -0,0 +1,168 @@ +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("Ingredient Entity") +class IngredientTest { + + @Nested + @DisplayName("create()") + class Create { + + @Test + @DisplayName("should create ingredient with valid inputs") + void should_CreateIngredient_When_ValidInputs() { + var draft = new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isSuccess()).isTrue(); + var ingredient = result.unsafeGetValue(); + assertThat(ingredient.id()).isNotNull(); + assertThat(ingredient.position()).isEqualTo(1); + assertThat(ingredient.articleId()).isEqualTo("article-123"); + assertThat(ingredient.quantity().amount()).isEqualByComparingTo("5.5"); + assertThat(ingredient.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(ingredient.subRecipeId()).isNull(); + assertThat(ingredient.substitutable()).isFalse(); + } + + @Test + @DisplayName("should fail when position is 0") + void should_Fail_When_PositionIsZero() { + var draft = new IngredientDraft(0, "article-123", "5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when position is negative") + void should_Fail_When_PositionIsNegative() { + var draft = new IngredientDraft(-1, "article-123", "5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when articleId is blank") + void should_Fail_When_ArticleIdIsBlank() { + var draft = new IngredientDraft(1, "", "5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when articleId is null") + void should_Fail_When_ArticleIdIsNull() { + var draft = new IngredientDraft(1, null, "5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when quantity is zero") + void should_Fail_When_QuantityIsZero() { + var draft = new IngredientDraft(1, "article-123", "0", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when quantity is negative") + void should_Fail_When_QuantityIsNegative() { + var draft = new IngredientDraft(1, "article-123", "-5", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when UoM is invalid") + void should_Fail_When_UomIsInvalid() { + var draft = new IngredientDraft(1, "article-123", "5", "INVALID", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when quantity is not a number") + void should_Fail_When_QuantityIsNotANumber() { + var draft = new IngredientDraft(1, "article-123", "abc", "KILOGRAM", null, false); + + var result = Ingredient.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class); + } + + @Test + @DisplayName("should create ingredient with subRecipeId") + void should_CreateIngredient_WithSubRecipeId() { + var draft = new IngredientDraft(1, "article-123", "10", "GRAM", "sub-recipe-456", true); + + var result = Ingredient.create(draft); + + assertThat(result.isSuccess()).isTrue(); + var ingredient = result.unsafeGetValue(); + assertThat(ingredient.subRecipeId()).isEqualTo("sub-recipe-456"); + assertThat(ingredient.substitutable()).isTrue(); + } + } + + @Nested + @DisplayName("Equality") + class Equality { + + @Test + @DisplayName("should be equal when same ID") + void should_BeEqual_When_SameId() { + var ingredient = Ingredient.create( + new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false) + ).unsafeGetValue(); + + var reconstituted = Ingredient.reconstitute( + ingredient.id(), 2, "other-article", ingredient.quantity(), "sub", true + ); + + assertThat(ingredient).isEqualTo(reconstituted); + assertThat(ingredient.hashCode()).isEqualTo(reconstituted.hashCode()); + } + + @Test + @DisplayName("should not be equal when different ID") + void should_NotBeEqual_When_DifferentId() { + var ingredient1 = Ingredient.create( + new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false) + ).unsafeGetValue(); + var ingredient2 = Ingredient.create( + new IngredientDraft(1, "article-123", "5", "KILOGRAM", null, false) + ).unsafeGetValue(); + + assertThat(ingredient1).isNotEqualTo(ingredient2); + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java index 8c91b36..2374c5a 100644 --- a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("Recipe Aggregate") @@ -17,6 +19,10 @@ class RecipeTest { ); } + private IngredientDraft validIngredientDraft(int position) { + return new IngredientDraft(position, "article-123", "5.5", "KILOGRAM", null, false); + } + @Nested @DisplayName("create()") class Create { @@ -38,6 +44,7 @@ class RecipeTest { assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100"); assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT); + assertThat(recipe.ingredients()).isEmpty(); assertThat(recipe.createdAt()).isNotNull(); assertThat(recipe.updatedAt()).isNotNull(); } @@ -199,6 +206,144 @@ class RecipeTest { } } + @Nested + @DisplayName("addIngredient()") + class AddIngredient { + + @Test + @DisplayName("should add ingredient to DRAFT recipe") + void should_AddIngredient_When_DraftStatus() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + + var result = recipe.addIngredient(validIngredientDraft(1)); + + assertThat(result.isSuccess()).isTrue(); + var ingredient = result.unsafeGetValue(); + assertThat(ingredient.id()).isNotNull(); + assertThat(ingredient.position()).isEqualTo(1); + assertThat(ingredient.articleId()).isEqualTo("article-123"); + assertThat(ingredient.quantity().amount()).isEqualByComparingTo("5.5"); + assertThat(ingredient.quantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(ingredient.substitutable()).isFalse(); + assertThat(recipe.ingredients()).hasSize(1); + } + + @Test + @DisplayName("should fail when recipe is ACTIVE") + void should_Fail_When_ActiveStatus() { + var recipe = Recipe.reconstitute( + RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), + java.time.LocalDateTime.now(), java.time.LocalDateTime.now() + ); + + var result = recipe.addIngredient(validIngredientDraft(1)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class); + } + + @Test + @DisplayName("should fail when position is duplicate") + void should_Fail_When_DuplicatePosition() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + recipe.addIngredient(validIngredientDraft(1)); + + var result = recipe.addIngredient(new IngredientDraft(1, "article-456", "3", "GRAM", null, false)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.DuplicatePosition.class); + } + + @Test + @DisplayName("should allow ingredient with subRecipeId") + void should_AllowIngredient_WithSubRecipeId() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + var draft = new IngredientDraft(1, "article-123", "10", "KILOGRAM", "sub-recipe-id", true); + + var result = recipe.addIngredient(draft); + + assertThat(result.isSuccess()).isTrue(); + var ingredient = result.unsafeGetValue(); + assertThat(ingredient.subRecipeId()).isEqualTo("sub-recipe-id"); + assertThat(ingredient.substitutable()).isTrue(); + } + + @Test + @DisplayName("should allow multiple ingredients with different positions") + void should_AllowMultipleIngredients_WithDifferentPositions() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + + recipe.addIngredient(validIngredientDraft(1)); + recipe.addIngredient(validIngredientDraft(2)); + recipe.addIngredient(validIngredientDraft(3)); + + assertThat(recipe.ingredients()).hasSize(3); + } + } + + @Nested + @DisplayName("removeIngredient()") + class RemoveIngredient { + + @Test + @DisplayName("should remove existing ingredient") + void should_RemoveIngredient_When_Exists() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + var ingredient = recipe.addIngredient(validIngredientDraft(1)).unsafeGetValue(); + + var result = recipe.removeIngredient(ingredient.id()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(recipe.ingredients()).isEmpty(); + } + + @Test + @DisplayName("should fail when ingredient not found") + void should_Fail_When_IngredientNotFound() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + + var result = recipe.removeIngredient(IngredientId.generate()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.IngredientNotFound.class); + } + + @Test + @DisplayName("should fail when recipe is not in DRAFT status") + void should_Fail_When_NotDraftStatus() { + var recipe = Recipe.reconstitute( + RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + RecipeStatus.ACTIVE, List.of(), + java.time.LocalDateTime.now(), java.time.LocalDateTime.now() + ); + + var result = recipe.removeIngredient(IngredientId.generate()); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class); + } + + @Test + @DisplayName("should allow gaps in positions after removal") + void should_AllowPositionGaps_AfterRemoval() { + var recipe = Recipe.create(validDraft()).unsafeGetValue(); + recipe.addIngredient(validIngredientDraft(1)); + var ingredient2 = recipe.addIngredient(validIngredientDraft(2)).unsafeGetValue(); + recipe.addIngredient(validIngredientDraft(3)); + + recipe.removeIngredient(ingredient2.id()); + + assertThat(recipe.ingredients()).hasSize(2); + assertThat(recipe.ingredients().stream().map(i -> i.position()).toList()) + .containsExactly(1, 3); + } + } + @Nested @DisplayName("Equality") class Equality { @@ -211,7 +356,7 @@ class RecipeTest { 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() + RecipeStatus.ACTIVE, List.of(), recipe1.createdAt(), recipe1.updatedAt() ); assertThat(recipe1).isEqualTo(recipe2);