mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
feat(production): Zutaten zum Rezept verwalten (#27)
Ingredient als Child Entity des Recipe Aggregates implementiert. Folgt dem Article → SalesUnit Pattern als Full Vertical Slice. Domain: IngredientId, Ingredient, IngredientDraft, Recipe.addIngredient/removeIngredient Application: AddRecipeIngredient, RemoveRecipeIngredient Use Cases Infrastructure: IngredientEntity (JPA), REST-Endpoints (POST/DELETE), Liquibase Migration Tests: RecipeTest (9 neue), IngredientTest (11 Tests)
This commit is contained in:
parent
dcaa43dc2c
commit
bee3f28b5f
22 changed files with 912 additions and 5 deletions
|
|
@ -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<RecipeError, Recipe> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RecipeError, Recipe> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record RemoveRecipeIngredientCommand(
|
||||
String recipeId,
|
||||
String ingredientId
|
||||
) {}
|
||||
|
|
@ -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<RecipeError, Ingredient> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Ingredient> ingredients;
|
||||
private final LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
|
|
@ -43,6 +49,7 @@ public class Recipe {
|
|||
Integer shelfLifeDays,
|
||||
Quantity outputQuantity,
|
||||
RecipeStatus status,
|
||||
List<Ingredient> 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<Ingredient> 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<RecipeError, Ingredient> 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<RecipeError, Void> 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<Ingredient> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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<IngredientEntity> 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<IngredientEntity> 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<IngredientEntity> ingredients) { this.ingredients = ingredients; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IngredientEntity> ingredientEntities = recipe.ingredients().stream()
|
||||
.map(i -> toIngredientEntity(i, entity))
|
||||
.collect(Collectors.toList());
|
||||
entity.setIngredients(ingredientEntities);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
public Recipe toDomain(RecipeEntity entity) {
|
||||
List<Ingredient> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecipeResponse> 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<Void> 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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IngredientResponse> 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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||
|
||||
<changeSet id="011-create-recipe-ingredients-table" author="effigenix">
|
||||
<createTable tableName="recipe_ingredients">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="recipe_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="position" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="article_id" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="quantity" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="uom" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="sub_recipe_id" type="VARCHAR(36)"/>
|
||||
<column name="substitutable" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="recipe_ingredients" baseColumnNames="recipe_id"
|
||||
referencedTableName="recipes" referencedColumnNames="id"
|
||||
constraintName="fk_recipe_ingredients_recipe"
|
||||
onDelete="CASCADE"/>
|
||||
|
||||
<addUniqueConstraint tableName="recipe_ingredients" columnNames="recipe_id, position"
|
||||
constraintName="uq_recipe_ingredient_position"/>
|
||||
|
||||
<createIndex tableName="recipe_ingredients" indexName="idx_recipe_ingredients_recipe_id">
|
||||
<column name="recipe_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -15,5 +15,6 @@
|
|||
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
|
||||
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
|
||||
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
|
||||
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue