mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:29:36 +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.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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 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()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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/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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue