mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
feat(production): Produktionsschritte zum Rezept verwalten + AuthorizationPort
Erweitert das Recipe-Aggregate um ProductionStep-Child-Entities (Add/Remove) mit vollständiger DDD-Konformität. Führt AuthorizationPort-Prüfung in allen Production Use Cases ein (analog zum usermanagement-Referenz-BC). Fixes: Request-Validierung (Size, Min/Max), Error-Code-Konsistenz, Defense-in-Depth für durationMinutes und temperatureCelsius.
This commit is contained in:
parent
c26d72fbe7
commit
cf93b847e5
27 changed files with 978 additions and 18 deletions
|
|
@ -0,0 +1,58 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.AddProductionStepCommand;
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class AddProductionStep {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public AddProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(AddProductionStepCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
|
||||
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
|
||||
}
|
||||
|
||||
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 ProductionStepDraft(
|
||||
cmd.stepNumber(), cmd.description(),
|
||||
cmd.durationMinutes(), cmd.temperatureCelsius()
|
||||
);
|
||||
|
||||
switch (recipe.addProductionStep(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,25 @@ 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 de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class AddRecipeIngredient {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public AddRecipeIngredient(RecipeRepository recipeRepository) {
|
||||
public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
|
||||
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
|
||||
}
|
||||
|
||||
var recipeId = RecipeId.of(cmd.recipeId());
|
||||
|
||||
Recipe recipe;
|
||||
|
|
|
|||
|
|
@ -4,18 +4,25 @@ import de.effigenix.application.production.command.CreateRecipeCommand;
|
|||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class CreateRecipe {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public CreateRecipe(RecipeRepository recipeRepository) {
|
||||
public CreateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
|
||||
return Result.failure(new RecipeError.Unauthorized("Not authorized to create recipes"));
|
||||
}
|
||||
|
||||
var draft = new RecipeDraft(
|
||||
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
|
||||
cmd.yieldPercentage(), cmd.shelfLifeDays(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.RemoveProductionStepCommand;
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import de.effigenix.shared.security.ActorId;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class RemoveProductionStep {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public RemoveProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(RemoveProductionStepCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
|
||||
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
|
||||
}
|
||||
|
||||
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.removeProductionStep(cmd.stepNumber())) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,18 +4,25 @@ 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 de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Transactional
|
||||
public class RemoveRecipeIngredient {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public RemoveRecipeIngredient(RecipeRepository recipeRepository) {
|
||||
public RemoveRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
|
||||
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
|
||||
}
|
||||
|
||||
var recipeId = RecipeId.of(cmd.recipeId());
|
||||
|
||||
Recipe recipe;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record AddProductionStepCommand(
|
||||
String recipeId,
|
||||
int stepNumber,
|
||||
String description,
|
||||
Integer durationMinutes,
|
||||
Integer temperatureCelsius
|
||||
) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record RemoveProductionStepCommand(
|
||||
String recipeId,
|
||||
int stepNumber
|
||||
) {}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
/**
|
||||
* Child entity of Recipe representing a single production step.
|
||||
*
|
||||
* Invariants:
|
||||
* 1. StepNumber must be >= 1
|
||||
* 2. Description must not be blank
|
||||
* 3. DurationMinutes must be > 0 if set
|
||||
* 4. TemperatureCelsius must be between -273 and 1000 if set
|
||||
*/
|
||||
public class ProductionStep {
|
||||
|
||||
private final ProductionStepId id;
|
||||
private final int stepNumber;
|
||||
private final String description;
|
||||
private final Integer durationMinutes;
|
||||
private final Integer temperatureCelsius;
|
||||
|
||||
private ProductionStep(ProductionStepId id, int stepNumber, String description,
|
||||
Integer durationMinutes, Integer temperatureCelsius) {
|
||||
this.id = id;
|
||||
this.stepNumber = stepNumber;
|
||||
this.description = description;
|
||||
this.durationMinutes = durationMinutes;
|
||||
this.temperatureCelsius = temperatureCelsius;
|
||||
}
|
||||
|
||||
public static Result<RecipeError, ProductionStep> create(ProductionStepDraft draft) {
|
||||
if (draft.stepNumber() < 1) {
|
||||
return Result.failure(new RecipeError.ValidationFailure(
|
||||
"Step number must be >= 1, was: " + draft.stepNumber()));
|
||||
}
|
||||
|
||||
if (draft.description() == null || draft.description().isBlank()) {
|
||||
return Result.failure(new RecipeError.ValidationFailure("Step description must not be blank"));
|
||||
}
|
||||
|
||||
if (draft.durationMinutes() != null && draft.durationMinutes() <= 0) {
|
||||
return Result.failure(new RecipeError.ValidationFailure(
|
||||
"Duration minutes must be > 0, was: " + draft.durationMinutes()));
|
||||
}
|
||||
|
||||
if (draft.temperatureCelsius() != null
|
||||
&& (draft.temperatureCelsius() < -273 || draft.temperatureCelsius() > 1000)) {
|
||||
return Result.failure(new RecipeError.ValidationFailure(
|
||||
"Temperature must be between -273 and 1000 Celsius, was: " + draft.temperatureCelsius()));
|
||||
}
|
||||
|
||||
return Result.success(new ProductionStep(
|
||||
ProductionStepId.generate(), draft.stepNumber(), draft.description(),
|
||||
draft.durationMinutes(), draft.temperatureCelsius()
|
||||
));
|
||||
}
|
||||
|
||||
public static ProductionStep reconstitute(ProductionStepId id, int stepNumber, String description,
|
||||
Integer durationMinutes, Integer temperatureCelsius) {
|
||||
return new ProductionStep(id, stepNumber, description, durationMinutes, temperatureCelsius);
|
||||
}
|
||||
|
||||
public ProductionStepId id() { return id; }
|
||||
public int stepNumber() { return stepNumber; }
|
||||
public String description() { return description; }
|
||||
public Integer durationMinutes() { return durationMinutes; }
|
||||
public Integer temperatureCelsius() { return temperatureCelsius; }
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (!(obj instanceof ProductionStep other)) return false;
|
||||
return id.equals(other.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
/**
|
||||
* Draft for adding a production step to a recipe. Application layer builds this from raw command inputs.
|
||||
* The ProductionStep entity validates internally.
|
||||
*
|
||||
* @param stepNumber Step number in the recipe (required, >= 1)
|
||||
* @param description Description of what to do in this step (required)
|
||||
* @param durationMinutes Optional duration in minutes
|
||||
* @param temperatureCelsius Optional temperature in Celsius
|
||||
*/
|
||||
public record ProductionStepDraft(
|
||||
int stepNumber,
|
||||
String description,
|
||||
Integer durationMinutes,
|
||||
Integer temperatureCelsius
|
||||
) {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record ProductionStepId(String value) {
|
||||
|
||||
public ProductionStepId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("ProductionStepId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static ProductionStepId generate() {
|
||||
return new ProductionStepId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static ProductionStepId of(String value) {
|
||||
return new ProductionStepId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import static de.effigenix.shared.common.Result.*;
|
|||
* 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
|
||||
* 9. ProductionSteps can only be added/removed in DRAFT status
|
||||
* 10. Step numbers must be unique within a recipe
|
||||
*/
|
||||
public class Recipe {
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ public class Recipe {
|
|||
private Quantity outputQuantity;
|
||||
private RecipeStatus status;
|
||||
private final List<Ingredient> ingredients;
|
||||
private final List<ProductionStep> productionSteps;
|
||||
private final LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
|
|
@ -50,6 +53,7 @@ public class Recipe {
|
|||
Quantity outputQuantity,
|
||||
RecipeStatus status,
|
||||
List<Ingredient> ingredients,
|
||||
List<ProductionStep> productionSteps,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
|
|
@ -63,6 +67,7 @@ public class Recipe {
|
|||
this.outputQuantity = outputQuantity;
|
||||
this.status = status;
|
||||
this.ingredients = new ArrayList<>(ingredients);
|
||||
this.productionSteps = new ArrayList<>(productionSteps);
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
|
@ -117,7 +122,7 @@ public class Recipe {
|
|||
return Result.success(new Recipe(
|
||||
RecipeId.generate(), name, draft.version(), draft.type(),
|
||||
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
|
||||
RecipeStatus.DRAFT, List.of(), now, now
|
||||
RecipeStatus.DRAFT, List.of(), List.of(), now, now
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -135,11 +140,12 @@ public class Recipe {
|
|||
Quantity outputQuantity,
|
||||
RecipeStatus status,
|
||||
List<Ingredient> ingredients,
|
||||
List<ProductionStep> productionSteps,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
return new Recipe(id, name, version, type, description,
|
||||
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, createdAt, updatedAt);
|
||||
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
// ==================== Ingredient Management ====================
|
||||
|
|
@ -175,6 +181,39 @@ public class Recipe {
|
|||
return Result.success(null);
|
||||
}
|
||||
|
||||
// ==================== ProductionStep Management ====================
|
||||
|
||||
public Result<RecipeError, ProductionStep> addProductionStep(ProductionStepDraft draft) {
|
||||
if (status != RecipeStatus.DRAFT) {
|
||||
return Result.failure(new RecipeError.NotInDraftStatus());
|
||||
}
|
||||
if (hasStepNumber(draft.stepNumber())) {
|
||||
return Result.failure(new RecipeError.DuplicateStepNumber(draft.stepNumber()));
|
||||
}
|
||||
|
||||
ProductionStep step;
|
||||
switch (ProductionStep.create(draft)) {
|
||||
case Failure(var err) -> { return Result.failure(err); }
|
||||
case Success(var val) -> step = val;
|
||||
}
|
||||
|
||||
this.productionSteps.add(step);
|
||||
touch();
|
||||
return Result.success(step);
|
||||
}
|
||||
|
||||
public Result<RecipeError, Void> removeProductionStep(int stepNumber) {
|
||||
if (status != RecipeStatus.DRAFT) {
|
||||
return Result.failure(new RecipeError.NotInDraftStatus());
|
||||
}
|
||||
boolean removed = this.productionSteps.removeIf(s -> s.stepNumber() == stepNumber);
|
||||
if (!removed) {
|
||||
return Result.failure(new RecipeError.StepNotFound(stepNumber));
|
||||
}
|
||||
touch();
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public RecipeId id() { return id; }
|
||||
|
|
@ -187,6 +226,7 @@ public class Recipe {
|
|||
public Quantity outputQuantity() { return outputQuantity; }
|
||||
public RecipeStatus status() { return status; }
|
||||
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
|
||||
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }
|
||||
public LocalDateTime createdAt() { return createdAt; }
|
||||
public LocalDateTime updatedAt() { return updatedAt; }
|
||||
|
||||
|
|
@ -196,6 +236,10 @@ public class Recipe {
|
|||
return ingredients.stream().anyMatch(i -> i.position() == position);
|
||||
}
|
||||
|
||||
private boolean hasStepNumber(int stepNumber) {
|
||||
return productionSteps.stream().anyMatch(s -> s.stepNumber() == stepNumber);
|
||||
}
|
||||
|
||||
private void touch() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,20 @@ public sealed interface RecipeError {
|
|||
}
|
||||
|
||||
record IngredientNotFound(IngredientId id) implements RecipeError {
|
||||
@Override public String code() { return "INGREDIENT_NOT_FOUND"; }
|
||||
@Override public String code() { return "RECIPE_INGREDIENT_NOT_FOUND"; }
|
||||
@Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; }
|
||||
}
|
||||
|
||||
record DuplicateStepNumber(int stepNumber) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_DUPLICATE_STEP_NUMBER"; }
|
||||
@Override public String message() { return "Step number " + stepNumber + " is already taken"; }
|
||||
}
|
||||
|
||||
record StepNotFound(int stepNumber) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_STEP_NOT_FOUND"; }
|
||||
@Override public String message() { return "Production step with number " + stepNumber + " not found"; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements RecipeError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.production.AddProductionStep;
|
||||
import de.effigenix.application.production.AddRecipeIngredient;
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.application.production.RemoveProductionStep;
|
||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
||||
import de.effigenix.domain.production.RecipeRepository;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
|
|
@ -11,17 +14,27 @@ import org.springframework.context.annotation.Configuration;
|
|||
public class ProductionUseCaseConfiguration {
|
||||
|
||||
@Bean
|
||||
public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
|
||||
return new CreateRecipe(recipeRepository);
|
||||
public CreateRecipe createRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new CreateRecipe(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) {
|
||||
return new AddRecipeIngredient(recipeRepository);
|
||||
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new AddRecipeIngredient(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) {
|
||||
return new RemoveRecipeIngredient(recipeRepository);
|
||||
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new RemoveRecipeIngredient(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AddProductionStep addProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new AddProductionStep(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new RemoveProductionStep(recipeRepository, authorizationPort);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package de.effigenix.infrastructure.production.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recipe_production_steps",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uq_recipe_step_number", columnNames = {"recipe_id", "step_number"}))
|
||||
public class ProductionStepEntity {
|
||||
|
||||
@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 = "step_number", nullable = false)
|
||||
private int stepNumber;
|
||||
|
||||
@Column(name = "description", nullable = false, length = 500)
|
||||
private String description;
|
||||
|
||||
@Column(name = "duration_minutes")
|
||||
private Integer durationMinutes;
|
||||
|
||||
@Column(name = "temperature_celsius")
|
||||
private Integer temperatureCelsius;
|
||||
|
||||
protected ProductionStepEntity() {}
|
||||
|
||||
public ProductionStepEntity(String id, RecipeEntity recipe, int stepNumber, String description,
|
||||
Integer durationMinutes, Integer temperatureCelsius) {
|
||||
this.id = id;
|
||||
this.recipe = recipe;
|
||||
this.stepNumber = stepNumber;
|
||||
this.description = description;
|
||||
this.durationMinutes = durationMinutes;
|
||||
this.temperatureCelsius = temperatureCelsius;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public RecipeEntity getRecipe() { return recipe; }
|
||||
public int getStepNumber() { return stepNumber; }
|
||||
public String getDescription() { return description; }
|
||||
public Integer getDurationMinutes() { return durationMinutes; }
|
||||
public Integer getTemperatureCelsius() { return temperatureCelsius; }
|
||||
|
||||
public void setId(String id) { this.id = id; }
|
||||
public void setRecipe(RecipeEntity recipe) { this.recipe = recipe; }
|
||||
public void setStepNumber(int stepNumber) { this.stepNumber = stepNumber; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
|
||||
public void setTemperatureCelsius(Integer temperatureCelsius) { this.temperatureCelsius = temperatureCelsius; }
|
||||
}
|
||||
|
|
@ -53,6 +53,10 @@ public class RecipeEntity {
|
|||
@OrderBy("position ASC")
|
||||
private List<IngredientEntity> ingredients = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
@OrderBy("stepNumber ASC")
|
||||
private List<ProductionStepEntity> productionSteps = new ArrayList<>();
|
||||
|
||||
protected RecipeEntity() {}
|
||||
|
||||
public RecipeEntity(String id, String name, int version, String type, String description,
|
||||
|
|
@ -85,6 +89,7 @@ public class RecipeEntity {
|
|||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public List<IngredientEntity> getIngredients() { return ingredients; }
|
||||
public List<ProductionStepEntity> getProductionSteps() { return productionSteps; }
|
||||
|
||||
public void setId(String id) { this.id = id; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
|
@ -99,4 +104,5 @@ public class RecipeEntity {
|
|||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
public void setIngredients(List<IngredientEntity> ingredients) { this.ingredients = ingredients; }
|
||||
public void setProductionSteps(List<ProductionStepEntity> productionSteps) { this.productionSteps = productionSteps; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package de.effigenix.infrastructure.production.persistence.mapper;
|
|||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
|
@ -32,6 +33,11 @@ public class RecipeMapper {
|
|||
.collect(Collectors.toList());
|
||||
entity.setIngredients(ingredientEntities);
|
||||
|
||||
List<ProductionStepEntity> stepEntities = recipe.productionSteps().stream()
|
||||
.map(s -> toProductionStepEntity(s, entity))
|
||||
.collect(Collectors.toList());
|
||||
entity.setProductionSteps(stepEntities);
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +46,10 @@ public class RecipeMapper {
|
|||
.map(this::toDomainIngredient)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<ProductionStep> productionSteps = entity.getProductionSteps().stream()
|
||||
.map(this::toDomainProductionStep)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return Recipe.reconstitute(
|
||||
RecipeId.of(entity.getId()),
|
||||
new RecipeName(entity.getName()),
|
||||
|
|
@ -55,6 +65,7 @@ public class RecipeMapper {
|
|||
),
|
||||
RecipeStatus.valueOf(entity.getStatus()),
|
||||
ingredients,
|
||||
productionSteps,
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
|
|
@ -73,6 +84,27 @@ public class RecipeMapper {
|
|||
);
|
||||
}
|
||||
|
||||
private ProductionStepEntity toProductionStepEntity(ProductionStep step, RecipeEntity recipe) {
|
||||
return new ProductionStepEntity(
|
||||
step.id().value(),
|
||||
recipe,
|
||||
step.stepNumber(),
|
||||
step.description(),
|
||||
step.durationMinutes(),
|
||||
step.temperatureCelsius()
|
||||
);
|
||||
}
|
||||
|
||||
private ProductionStep toDomainProductionStep(ProductionStepEntity entity) {
|
||||
return ProductionStep.reconstitute(
|
||||
ProductionStepId.of(entity.getId()),
|
||||
entity.getStepNumber(),
|
||||
entity.getDescription(),
|
||||
entity.getDurationMinutes(),
|
||||
entity.getTemperatureCelsius()
|
||||
);
|
||||
}
|
||||
|
||||
private Ingredient toDomainIngredient(IngredientEntity entity) {
|
||||
return Ingredient.reconstitute(
|
||||
IngredientId.of(entity.getId()),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
package de.effigenix.infrastructure.production.web.controller;
|
||||
|
||||
import de.effigenix.application.production.AddProductionStep;
|
||||
import de.effigenix.application.production.AddRecipeIngredient;
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.application.production.RemoveProductionStep;
|
||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
||||
import de.effigenix.application.production.command.AddProductionStepCommand;
|
||||
import de.effigenix.application.production.command.AddRecipeIngredientCommand;
|
||||
import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||
import de.effigenix.application.production.command.RemoveProductionStepCommand;
|
||||
import de.effigenix.application.production.command.RemoveRecipeIngredientCommand;
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
import de.effigenix.infrastructure.production.web.dto.AddProductionStepRequest;
|
||||
import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest;
|
||||
import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest;
|
||||
import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
|
||||
|
|
@ -33,13 +38,19 @@ public class RecipeController {
|
|||
private final CreateRecipe createRecipe;
|
||||
private final AddRecipeIngredient addRecipeIngredient;
|
||||
private final RemoveRecipeIngredient removeRecipeIngredient;
|
||||
private final AddProductionStep addProductionStep;
|
||||
private final RemoveProductionStep removeProductionStep;
|
||||
|
||||
public RecipeController(CreateRecipe createRecipe,
|
||||
AddRecipeIngredient addRecipeIngredient,
|
||||
RemoveRecipeIngredient removeRecipeIngredient) {
|
||||
RemoveRecipeIngredient removeRecipeIngredient,
|
||||
AddProductionStep addProductionStep,
|
||||
RemoveProductionStep removeProductionStep) {
|
||||
this.createRecipe = createRecipe;
|
||||
this.addRecipeIngredient = addRecipeIngredient;
|
||||
this.removeRecipeIngredient = removeRecipeIngredient;
|
||||
this.addProductionStep = addProductionStep;
|
||||
this.removeProductionStep = removeProductionStep;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
|
@ -111,6 +122,51 @@ public class RecipeController {
|
|||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/steps")
|
||||
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
||||
public ResponseEntity<RecipeResponse> addProductionStep(
|
||||
@PathVariable("id") String recipeId,
|
||||
@Valid @RequestBody AddProductionStepRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
var actorId = extractActorId(authentication);
|
||||
logger.info("Adding production step to recipe: {} by actor: {}", recipeId, actorId.value());
|
||||
|
||||
var cmd = new AddProductionStepCommand(
|
||||
recipeId, request.stepNumber(), request.description(),
|
||||
request.durationMinutes(), request.temperatureCelsius()
|
||||
);
|
||||
var result = addProductionStep.execute(cmd, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Production step added to recipe: {}", recipeId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/steps/{stepNumber}")
|
||||
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
||||
public ResponseEntity<Void> removeProductionStep(
|
||||
@PathVariable("id") String recipeId,
|
||||
@PathVariable("stepNumber") int stepNumber,
|
||||
Authentication authentication
|
||||
) {
|
||||
var actorId = extractActorId(authentication);
|
||||
logger.info("Removing production step {} from recipe: {} by actor: {}", stepNumber, recipeId, actorId.value());
|
||||
|
||||
var cmd = new RemoveProductionStepCommand(recipeId, stepNumber);
|
||||
var result = removeProductionStep.execute(cmd, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Production step removed from recipe: {}", recipeId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AddProductionStepRequest(
|
||||
@Min(1) int stepNumber,
|
||||
@NotBlank @Size(max = 500) String description,
|
||||
@Min(1) Integer durationMinutes,
|
||||
@Min(-273) @Max(1000) Integer temperatureCelsius
|
||||
) {}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import de.effigenix.domain.production.ProductionStep;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(requiredProperties = {"id", "stepNumber", "description"})
|
||||
public record ProductionStepResponse(
|
||||
String id,
|
||||
int stepNumber,
|
||||
String description,
|
||||
@Schema(nullable = true) Integer durationMinutes,
|
||||
@Schema(nullable = true) Integer temperatureCelsius
|
||||
) {
|
||||
public static ProductionStepResponse from(ProductionStep step) {
|
||||
return new ProductionStepResponse(
|
||||
step.id().value(),
|
||||
step.stepNumber(),
|
||||
step.description(),
|
||||
step.durationMinutes(),
|
||||
step.temperatureCelsius()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "createdAt", "updatedAt"})
|
||||
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"})
|
||||
public record RecipeResponse(
|
||||
String id,
|
||||
String name,
|
||||
|
|
@ -19,6 +19,7 @@ public record RecipeResponse(
|
|||
String outputUom,
|
||||
String status,
|
||||
List<IngredientResponse> ingredients,
|
||||
List<ProductionStepResponse> productionSteps,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
|
|
@ -35,6 +36,7 @@ public record RecipeResponse(
|
|||
recipe.outputQuantity().uom().name(),
|
||||
recipe.status().name(),
|
||||
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
|
||||
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),
|
||||
recipe.createdAt(),
|
||||
recipe.updatedAt()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ public final class ProductionErrorHttpStatusMapper {
|
|||
return switch (error) {
|
||||
case RecipeError.RecipeNotFound e -> 404;
|
||||
case RecipeError.IngredientNotFound e -> 404;
|
||||
case RecipeError.StepNotFound e -> 404;
|
||||
case RecipeError.NameAndVersionAlreadyExists e -> 409;
|
||||
case RecipeError.DuplicatePosition e -> 409;
|
||||
case RecipeError.DuplicateStepNumber e -> 409;
|
||||
case RecipeError.InvalidShelfLife e -> 400;
|
||||
case RecipeError.ValidationFailure e -> 400;
|
||||
case RecipeError.NotInDraftStatus e -> 409;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?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="012-create-recipe-production-steps-table" author="effigenix">
|
||||
<createTable tableName="recipe_production_steps">
|
||||
<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="step_number" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="VARCHAR(500)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="duration_minutes" type="INT"/>
|
||||
<column name="temperature_celsius" type="INT"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="recipe_production_steps" baseColumnNames="recipe_id"
|
||||
referencedTableName="recipes" referencedColumnNames="id"
|
||||
constraintName="fk_recipe_production_steps_recipe"
|
||||
onDelete="CASCADE"/>
|
||||
|
||||
<addUniqueConstraint tableName="recipe_production_steps" columnNames="recipe_id, step_number"
|
||||
constraintName="uq_recipe_step_number"/>
|
||||
|
||||
<createIndex tableName="recipe_production_steps" indexName="idx_recipe_production_steps_recipe_id">
|
||||
<column name="recipe_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -16,5 +16,6 @@
|
|||
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
|
||||
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
|
||||
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
||||
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue