1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 14:09:34 +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:
Sebastian Frick 2026-02-19 12:52:24 +01:00
parent dcaa43dc2c
commit bee3f28b5f
22 changed files with 912 additions and 5 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.application.production.command;
public record RemoveRecipeIngredientCommand(
String recipeId,
String ingredientId
) {}

View file

@ -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();
}
}

View file

@ -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");
}
}

View file

@ -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);
}
}

View file

@ -4,6 +4,9 @@ import de.effigenix.shared.common.Result;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import static de.effigenix.shared.common.Result.*; 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 * 4. ShelfLifeDays > 0 for FINISHED_PRODUCT and INTERMEDIATE
* 5. OutputQuantity must be positive * 5. OutputQuantity must be positive
* 6. New recipes always start in DRAFT status * 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 { public class Recipe {
@ -30,6 +35,7 @@ public class Recipe {
private Integer shelfLifeDays; private Integer shelfLifeDays;
private Quantity outputQuantity; private Quantity outputQuantity;
private RecipeStatus status; private RecipeStatus status;
private final List<Ingredient> ingredients;
private final LocalDateTime createdAt; private final LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@ -43,6 +49,7 @@ public class Recipe {
Integer shelfLifeDays, Integer shelfLifeDays,
Quantity outputQuantity, Quantity outputQuantity,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
@ -55,6 +62,7 @@ public class Recipe {
this.shelfLifeDays = shelfLifeDays; this.shelfLifeDays = shelfLifeDays;
this.outputQuantity = outputQuantity; this.outputQuantity = outputQuantity;
this.status = status; this.status = status;
this.ingredients = new ArrayList<>(ingredients);
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
@ -109,7 +117,7 @@ public class Recipe {
return Result.success(new Recipe( return Result.success(new Recipe(
RecipeId.generate(), name, draft.version(), draft.type(), RecipeId.generate(), name, draft.version(), draft.type(),
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
RecipeStatus.DRAFT, now, now RecipeStatus.DRAFT, List.of(), now, now
)); ));
} }
@ -126,11 +134,45 @@ public class Recipe {
Integer shelfLifeDays, Integer shelfLifeDays,
Quantity outputQuantity, Quantity outputQuantity,
RecipeStatus status, RecipeStatus status,
List<Ingredient> ingredients,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
return new Recipe(id, name, version, type, description, 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 ==================== // ==================== Getters ====================
@ -144,11 +186,16 @@ public class Recipe {
public Integer shelfLifeDays() { return shelfLifeDays; } public Integer shelfLifeDays() { return shelfLifeDays; }
public Quantity outputQuantity() { return outputQuantity; } public Quantity outputQuantity() { return outputQuantity; }
public RecipeStatus status() { return status; } public RecipeStatus status() { return status; }
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
public LocalDateTime createdAt() { return createdAt; } public LocalDateTime createdAt() { return createdAt; }
public LocalDateTime updatedAt() { return updatedAt; } public LocalDateTime updatedAt() { return updatedAt; }
// ==================== Helpers ==================== // ==================== Helpers ====================
private boolean hasPosition(int position) {
return ingredients.stream().anyMatch(i -> i.position() == position);
}
private void touch() { private void touch() {
this.updatedAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now();
} }

View file

@ -24,6 +24,21 @@ public sealed interface RecipeError {
@Override public String code() { return "RECIPE_VALIDATION_ERROR"; } @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 { record Unauthorized(String message) implements RecipeError {
@Override public String code() { return "UNAUTHORIZED"; } @Override public String code() { return "UNAUTHORIZED"; }
} }

View file

@ -1,6 +1,8 @@
package de.effigenix.infrastructure.config; package de.effigenix.infrastructure.config;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.RecipeRepository; import de.effigenix.domain.production.RecipeRepository;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -12,4 +14,14 @@ public class ProductionUseCaseConfiguration {
public CreateRecipe createRecipe(RecipeRepository recipeRepository) { public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
return new CreateRecipe(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);
}
} }

View file

@ -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; }
}

View file

@ -4,6 +4,8 @@ import jakarta.persistence.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity @Entity
@Table(name = "recipes", @Table(name = "recipes",
@ -47,6 +49,10 @@ public class RecipeEntity {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt; 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() {} protected RecipeEntity() {}
public RecipeEntity(String id, String name, int version, String type, String description, 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 String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; } public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<IngredientEntity> getIngredients() { return ingredients; }
public void setId(String id) { this.id = id; } public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; } 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 setStatus(String status) { this.status = status; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setIngredients(List<IngredientEntity> ingredients) { this.ingredients = ingredients; }
} }

View file

@ -1,14 +1,18 @@
package de.effigenix.infrastructure.production.persistence.mapper; package de.effigenix.infrastructure.production.persistence.mapper;
import de.effigenix.domain.production.*; import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component @Component
public class RecipeMapper { public class RecipeMapper {
public RecipeEntity toEntity(Recipe recipe) { public RecipeEntity toEntity(Recipe recipe) {
return new RecipeEntity( var entity = new RecipeEntity(
recipe.id().value(), recipe.id().value(),
recipe.name().value(), recipe.name().value(),
recipe.version(), recipe.version(),
@ -22,9 +26,20 @@ public class RecipeMapper {
recipe.createdAt(), recipe.createdAt(),
recipe.updatedAt() 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) { public Recipe toDomain(RecipeEntity entity) {
List<Ingredient> ingredients = entity.getIngredients().stream()
.map(this::toDomainIngredient)
.collect(Collectors.toList());
return Recipe.reconstitute( return Recipe.reconstitute(
RecipeId.of(entity.getId()), RecipeId.of(entity.getId()),
new RecipeName(entity.getName()), new RecipeName(entity.getName()),
@ -39,8 +54,37 @@ public class RecipeMapper {
null, null null, null
), ),
RecipeStatus.valueOf(entity.getStatus()), RecipeStatus.valueOf(entity.getStatus()),
ingredients,
entity.getCreatedAt(), entity.getCreatedAt(),
entity.getUpdatedAt() 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()
);
}
} }

View file

@ -1,8 +1,13 @@
package de.effigenix.infrastructure.production.web.controller; package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe; 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.CreateRecipeCommand;
import de.effigenix.application.production.command.RemoveRecipeIngredientCommand;
import de.effigenix.domain.production.RecipeError; 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.CreateRecipeRequest;
import de.effigenix.infrastructure.production.web.dto.RecipeResponse; import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
@ -26,9 +31,15 @@ public class RecipeController {
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class); private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
private final CreateRecipe createRecipe; 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.createRecipe = createRecipe;
this.addRecipeIngredient = addRecipeIngredient;
this.removeRecipeIngredient = removeRecipeIngredient;
} }
@PostMapping @PostMapping
@ -55,6 +66,51 @@ public class RecipeController {
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue())); 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) { private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) { if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext"); throw new IllegalStateException("No authentication found in SecurityContext");

View file

@ -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
) {}

View file

@ -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()
);
}
}

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Recipe; import de.effigenix.domain.production.Recipe;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
public record RecipeResponse( public record RecipeResponse(
String id, String id,
@ -15,6 +16,7 @@ public record RecipeResponse(
String outputQuantity, String outputQuantity,
String outputUom, String outputUom,
String status, String status,
List<IngredientResponse> ingredients,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt
) { ) {
@ -30,6 +32,7 @@ public record RecipeResponse(
recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().amount().toPlainString(),
recipe.outputQuantity().uom().name(), recipe.outputQuantity().uom().name(),
recipe.status().name(), recipe.status().name(),
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
recipe.createdAt(), recipe.createdAt(),
recipe.updatedAt() recipe.updatedAt()
); );

View file

@ -9,9 +9,12 @@ public final class ProductionErrorHttpStatusMapper {
public static int toHttpStatus(RecipeError error) { public static int toHttpStatus(RecipeError error) {
return switch (error) { return switch (error) {
case RecipeError.RecipeNotFound e -> 404; case RecipeError.RecipeNotFound e -> 404;
case RecipeError.IngredientNotFound e -> 404;
case RecipeError.NameAndVersionAlreadyExists e -> 409; case RecipeError.NameAndVersionAlreadyExists e -> 409;
case RecipeError.DuplicatePosition e -> 409;
case RecipeError.InvalidShelfLife e -> 400; case RecipeError.InvalidShelfLife e -> 400;
case RecipeError.ValidationFailure e -> 400; case RecipeError.ValidationFailure e -> 400;
case RecipeError.NotInDraftStatus e -> 409;
case RecipeError.Unauthorized e -> 403; case RecipeError.Unauthorized e -> 403;
case RecipeError.RepositoryFailure e -> 500; case RecipeError.RepositoryFailure e -> 500;
}; };

View file

@ -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>

View file

@ -15,5 +15,6 @@
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/> <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/009-create-storage-location-schema.xml"/>
<include file="db/changelog/changes/010-create-recipe-schema.xml"/> <include file="db/changelog/changes/010-create-recipe-schema.xml"/>
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -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);
}
}
}

View file

@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Recipe Aggregate") @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 @Nested
@DisplayName("create()") @DisplayName("create()")
class Create { class Create {
@ -38,6 +44,7 @@ class RecipeTest {
assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100"); assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100");
assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT); assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
assertThat(recipe.ingredients()).isEmpty();
assertThat(recipe.createdAt()).isNotNull(); assertThat(recipe.createdAt()).isNotNull();
assertThat(recipe.updatedAt()).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 @Nested
@DisplayName("Equality") @DisplayName("Equality")
class Equality { class Equality {
@ -211,7 +356,7 @@ class RecipeTest {
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL, recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
null, new YieldPercentage(100), null, null, new YieldPercentage(100), null,
Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), 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); assertThat(recipe1).isEqualTo(recipe2);