mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:59:36 +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.domain.production.*;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
import de.effigenix.shared.security.ActorId;
|
import de.effigenix.shared.security.ActorId;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public class AddRecipeIngredient {
|
public class AddRecipeIngredient {
|
||||||
|
|
||||||
private final RecipeRepository recipeRepository;
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final AuthorizationPort authorizationPort;
|
||||||
|
|
||||||
public AddRecipeIngredient(RecipeRepository recipeRepository) {
|
public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.authorizationPort = authorizationPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) {
|
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());
|
var recipeId = RecipeId.of(cmd.recipeId());
|
||||||
|
|
||||||
Recipe recipe;
|
Recipe recipe;
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,25 @@ import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||||
import de.effigenix.domain.production.*;
|
import de.effigenix.domain.production.*;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
import de.effigenix.shared.security.ActorId;
|
import de.effigenix.shared.security.ActorId;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public class CreateRecipe {
|
public class CreateRecipe {
|
||||||
|
|
||||||
private final RecipeRepository recipeRepository;
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final AuthorizationPort authorizationPort;
|
||||||
|
|
||||||
public CreateRecipe(RecipeRepository recipeRepository) {
|
public CreateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.authorizationPort = authorizationPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
|
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(
|
var draft = new RecipeDraft(
|
||||||
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
|
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
|
||||||
cmd.yieldPercentage(), cmd.shelfLifeDays(),
|
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.domain.production.*;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
import de.effigenix.shared.security.ActorId;
|
import de.effigenix.shared.security.ActorId;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public class RemoveRecipeIngredient {
|
public class RemoveRecipeIngredient {
|
||||||
|
|
||||||
private final RecipeRepository recipeRepository;
|
private final RecipeRepository recipeRepository;
|
||||||
|
private final AuthorizationPort authorizationPort;
|
||||||
|
|
||||||
public RemoveRecipeIngredient(RecipeRepository recipeRepository) {
|
public RemoveRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
this.recipeRepository = recipeRepository;
|
this.recipeRepository = recipeRepository;
|
||||||
|
this.authorizationPort = authorizationPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Result<RecipeError, Recipe> execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) {
|
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());
|
var recipeId = RecipeId.of(cmd.recipeId());
|
||||||
|
|
||||||
Recipe recipe;
|
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
|
* 6. New recipes always start in DRAFT status
|
||||||
* 7. Ingredients can only be added/removed in DRAFT status
|
* 7. Ingredients can only be added/removed in DRAFT status
|
||||||
* 8. Ingredient positions must be unique within a recipe
|
* 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 {
|
public class Recipe {
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ public class Recipe {
|
||||||
private Quantity outputQuantity;
|
private Quantity outputQuantity;
|
||||||
private RecipeStatus status;
|
private RecipeStatus status;
|
||||||
private final List<Ingredient> ingredients;
|
private final List<Ingredient> ingredients;
|
||||||
|
private final List<ProductionStep> productionSteps;
|
||||||
private final LocalDateTime createdAt;
|
private final LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
|
@ -50,6 +53,7 @@ public class Recipe {
|
||||||
Quantity outputQuantity,
|
Quantity outputQuantity,
|
||||||
RecipeStatus status,
|
RecipeStatus status,
|
||||||
List<Ingredient> ingredients,
|
List<Ingredient> ingredients,
|
||||||
|
List<ProductionStep> productionSteps,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
LocalDateTime updatedAt
|
LocalDateTime updatedAt
|
||||||
) {
|
) {
|
||||||
|
|
@ -63,6 +67,7 @@ public class Recipe {
|
||||||
this.outputQuantity = outputQuantity;
|
this.outputQuantity = outputQuantity;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.ingredients = new ArrayList<>(ingredients);
|
this.ingredients = new ArrayList<>(ingredients);
|
||||||
|
this.productionSteps = new ArrayList<>(productionSteps);
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +122,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, List.of(), now, now
|
RecipeStatus.DRAFT, List.of(), List.of(), now, now
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,11 +140,12 @@ public class Recipe {
|
||||||
Quantity outputQuantity,
|
Quantity outputQuantity,
|
||||||
RecipeStatus status,
|
RecipeStatus status,
|
||||||
List<Ingredient> ingredients,
|
List<Ingredient> ingredients,
|
||||||
|
List<ProductionStep> productionSteps,
|
||||||
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, ingredients, createdAt, updatedAt);
|
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Ingredient Management ====================
|
// ==================== Ingredient Management ====================
|
||||||
|
|
@ -175,6 +181,39 @@ public class Recipe {
|
||||||
return Result.success(null);
|
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 ====================
|
// ==================== Getters ====================
|
||||||
|
|
||||||
public RecipeId id() { return id; }
|
public RecipeId id() { return id; }
|
||||||
|
|
@ -187,6 +226,7 @@ public class Recipe {
|
||||||
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 List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
|
||||||
|
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }
|
||||||
public LocalDateTime createdAt() { return createdAt; }
|
public LocalDateTime createdAt() { return createdAt; }
|
||||||
public LocalDateTime updatedAt() { return updatedAt; }
|
public LocalDateTime updatedAt() { return updatedAt; }
|
||||||
|
|
||||||
|
|
@ -196,6 +236,10 @@ public class Recipe {
|
||||||
return ingredients.stream().anyMatch(i -> i.position() == position);
|
return ingredients.stream().anyMatch(i -> i.position() == position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean hasStepNumber(int stepNumber) {
|
||||||
|
return productionSteps.stream().anyMatch(s -> s.stepNumber() == stepNumber);
|
||||||
|
}
|
||||||
|
|
||||||
private void touch() {
|
private void touch() {
|
||||||
this.updatedAt = LocalDateTime.now();
|
this.updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,20 @@ public sealed interface RecipeError {
|
||||||
}
|
}
|
||||||
|
|
||||||
record IngredientNotFound(IngredientId id) implements 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"; }
|
@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 {
|
record Unauthorized(String message) implements RecipeError {
|
||||||
@Override public String code() { return "UNAUTHORIZED"; }
|
@Override public String code() { return "UNAUTHORIZED"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package de.effigenix.infrastructure.config;
|
package de.effigenix.infrastructure.config;
|
||||||
|
|
||||||
|
import de.effigenix.application.production.AddProductionStep;
|
||||||
import de.effigenix.application.production.AddRecipeIngredient;
|
import de.effigenix.application.production.AddRecipeIngredient;
|
||||||
import de.effigenix.application.production.CreateRecipe;
|
import de.effigenix.application.production.CreateRecipe;
|
||||||
|
import de.effigenix.application.production.RemoveProductionStep;
|
||||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
import de.effigenix.application.production.RemoveRecipeIngredient;
|
||||||
import de.effigenix.domain.production.RecipeRepository;
|
import de.effigenix.domain.production.RecipeRepository;
|
||||||
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
|
@ -11,17 +14,27 @@ import org.springframework.context.annotation.Configuration;
|
||||||
public class ProductionUseCaseConfiguration {
|
public class ProductionUseCaseConfiguration {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
|
public CreateRecipe createRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
return new CreateRecipe(recipeRepository);
|
return new CreateRecipe(recipeRepository, authorizationPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) {
|
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
return new AddRecipeIngredient(recipeRepository);
|
return new AddRecipeIngredient(recipeRepository, authorizationPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) {
|
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
return new RemoveRecipeIngredient(recipeRepository);
|
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")
|
@OrderBy("position ASC")
|
||||||
private List<IngredientEntity> ingredients = new ArrayList<>();
|
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() {}
|
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,
|
||||||
|
|
@ -85,6 +89,7 @@ public class RecipeEntity {
|
||||||
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 List<IngredientEntity> getIngredients() { return ingredients; }
|
||||||
|
public List<ProductionStepEntity> getProductionSteps() { return productionSteps; }
|
||||||
|
|
||||||
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; }
|
||||||
|
|
@ -99,4 +104,5 @@ public class RecipeEntity {
|
||||||
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; }
|
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.domain.production.*;
|
||||||
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
|
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 de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|
@ -32,6 +33,11 @@ public class RecipeMapper {
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
entity.setIngredients(ingredientEntities);
|
entity.setIngredients(ingredientEntities);
|
||||||
|
|
||||||
|
List<ProductionStepEntity> stepEntities = recipe.productionSteps().stream()
|
||||||
|
.map(s -> toProductionStepEntity(s, entity))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
entity.setProductionSteps(stepEntities);
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +46,10 @@ public class RecipeMapper {
|
||||||
.map(this::toDomainIngredient)
|
.map(this::toDomainIngredient)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<ProductionStep> productionSteps = entity.getProductionSteps().stream()
|
||||||
|
.map(this::toDomainProductionStep)
|
||||||
|
.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()),
|
||||||
|
|
@ -55,6 +65,7 @@ public class RecipeMapper {
|
||||||
),
|
),
|
||||||
RecipeStatus.valueOf(entity.getStatus()),
|
RecipeStatus.valueOf(entity.getStatus()),
|
||||||
ingredients,
|
ingredients,
|
||||||
|
productionSteps,
|
||||||
entity.getCreatedAt(),
|
entity.getCreatedAt(),
|
||||||
entity.getUpdatedAt()
|
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) {
|
private Ingredient toDomainIngredient(IngredientEntity entity) {
|
||||||
return Ingredient.reconstitute(
|
return Ingredient.reconstitute(
|
||||||
IngredientId.of(entity.getId()),
|
IngredientId.of(entity.getId()),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
package de.effigenix.infrastructure.production.web.controller;
|
package de.effigenix.infrastructure.production.web.controller;
|
||||||
|
|
||||||
|
import de.effigenix.application.production.AddProductionStep;
|
||||||
import de.effigenix.application.production.AddRecipeIngredient;
|
import de.effigenix.application.production.AddRecipeIngredient;
|
||||||
import de.effigenix.application.production.CreateRecipe;
|
import de.effigenix.application.production.CreateRecipe;
|
||||||
|
import de.effigenix.application.production.RemoveProductionStep;
|
||||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
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.AddRecipeIngredientCommand;
|
||||||
import de.effigenix.application.production.command.CreateRecipeCommand;
|
import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||||
|
import de.effigenix.application.production.command.RemoveProductionStepCommand;
|
||||||
import de.effigenix.application.production.command.RemoveRecipeIngredientCommand;
|
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.AddProductionStepRequest;
|
||||||
import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest;
|
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;
|
||||||
|
|
@ -33,13 +38,19 @@ public class RecipeController {
|
||||||
private final CreateRecipe createRecipe;
|
private final CreateRecipe createRecipe;
|
||||||
private final AddRecipeIngredient addRecipeIngredient;
|
private final AddRecipeIngredient addRecipeIngredient;
|
||||||
private final RemoveRecipeIngredient removeRecipeIngredient;
|
private final RemoveRecipeIngredient removeRecipeIngredient;
|
||||||
|
private final AddProductionStep addProductionStep;
|
||||||
|
private final RemoveProductionStep removeProductionStep;
|
||||||
|
|
||||||
public RecipeController(CreateRecipe createRecipe,
|
public RecipeController(CreateRecipe createRecipe,
|
||||||
AddRecipeIngredient addRecipeIngredient,
|
AddRecipeIngredient addRecipeIngredient,
|
||||||
RemoveRecipeIngredient removeRecipeIngredient) {
|
RemoveRecipeIngredient removeRecipeIngredient,
|
||||||
|
AddProductionStep addProductionStep,
|
||||||
|
RemoveProductionStep removeProductionStep) {
|
||||||
this.createRecipe = createRecipe;
|
this.createRecipe = createRecipe;
|
||||||
this.addRecipeIngredient = addRecipeIngredient;
|
this.addRecipeIngredient = addRecipeIngredient;
|
||||||
this.removeRecipeIngredient = removeRecipeIngredient;
|
this.removeRecipeIngredient = removeRecipeIngredient;
|
||||||
|
this.addProductionStep = addProductionStep;
|
||||||
|
this.removeProductionStep = removeProductionStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -111,6 +122,51 @@ public class RecipeController {
|
||||||
return ResponseEntity.noContent().build();
|
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) {
|
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.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.time.LocalDateTime;
|
||||||
import java.util.List;
|
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(
|
public record RecipeResponse(
|
||||||
String id,
|
String id,
|
||||||
String name,
|
String name,
|
||||||
|
|
@ -19,6 +19,7 @@ public record RecipeResponse(
|
||||||
String outputUom,
|
String outputUom,
|
||||||
String status,
|
String status,
|
||||||
List<IngredientResponse> ingredients,
|
List<IngredientResponse> ingredients,
|
||||||
|
List<ProductionStepResponse> productionSteps,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
LocalDateTime updatedAt
|
LocalDateTime updatedAt
|
||||||
) {
|
) {
|
||||||
|
|
@ -35,6 +36,7 @@ public record RecipeResponse(
|
||||||
recipe.outputQuantity().uom().name(),
|
recipe.outputQuantity().uom().name(),
|
||||||
recipe.status().name(),
|
recipe.status().name(),
|
||||||
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
|
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
|
||||||
|
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),
|
||||||
recipe.createdAt(),
|
recipe.createdAt(),
|
||||||
recipe.updatedAt()
|
recipe.updatedAt()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ public final class ProductionErrorHttpStatusMapper {
|
||||||
return switch (error) {
|
return switch (error) {
|
||||||
case RecipeError.RecipeNotFound e -> 404;
|
case RecipeError.RecipeNotFound e -> 404;
|
||||||
case RecipeError.IngredientNotFound e -> 404;
|
case RecipeError.IngredientNotFound e -> 404;
|
||||||
|
case RecipeError.StepNotFound e -> 404;
|
||||||
case RecipeError.NameAndVersionAlreadyExists e -> 409;
|
case RecipeError.NameAndVersionAlreadyExists e -> 409;
|
||||||
case RecipeError.DuplicatePosition e -> 409;
|
case RecipeError.DuplicatePosition e -> 409;
|
||||||
|
case RecipeError.DuplicateStepNumber 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.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/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"/>
|
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
|
||||||
|
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
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("ProductionStep Entity")
|
||||||
|
class ProductionStepTest {
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("create()")
|
||||||
|
class Create {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should create production step with valid inputs")
|
||||||
|
void should_CreateStep_When_ValidInputs() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Fleisch wolfen", 15, 4);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
var step = result.unsafeGetValue();
|
||||||
|
assertThat(step.id()).isNotNull();
|
||||||
|
assertThat(step.stepNumber()).isEqualTo(1);
|
||||||
|
assertThat(step.description()).isEqualTo("Fleisch wolfen");
|
||||||
|
assertThat(step.durationMinutes()).isEqualTo(15);
|
||||||
|
assertThat(step.temperatureCelsius()).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should create production step with nullable fields as null")
|
||||||
|
void should_CreateStep_When_NullableFieldsAreNull() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Mischen", null, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
var step = result.unsafeGetValue();
|
||||||
|
assertThat(step.durationMinutes()).isNull();
|
||||||
|
assertThat(step.temperatureCelsius()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when step number is 0")
|
||||||
|
void should_Fail_When_StepNumberIsZero() {
|
||||||
|
var draft = new ProductionStepDraft(0, "Mischen", null, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when step number is negative")
|
||||||
|
void should_Fail_When_StepNumberIsNegative() {
|
||||||
|
var draft = new ProductionStepDraft(-1, "Mischen", null, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when description is blank")
|
||||||
|
void should_Fail_When_DescriptionIsBlank() {
|
||||||
|
var draft = new ProductionStepDraft(1, "", null, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when description is null")
|
||||||
|
void should_Fail_When_DescriptionIsNull() {
|
||||||
|
var draft = new ProductionStepDraft(1, null, null, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when duration minutes is zero")
|
||||||
|
void should_Fail_When_DurationMinutesIsZero() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Mischen", 0, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when duration minutes is negative")
|
||||||
|
void should_Fail_When_DurationMinutesIsNegative() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Mischen", -5, null);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when temperature is below absolute zero")
|
||||||
|
void should_Fail_When_TemperatureBelowAbsoluteZero() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Mischen", null, -274);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when temperature is above 1000")
|
||||||
|
void should_Fail_When_TemperatureAbove1000() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Mischen", null, 1001);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should allow negative temperature within valid range")
|
||||||
|
void should_AllowNegativeTemperature_WhenInRange() {
|
||||||
|
var draft = new ProductionStepDraft(1, "Tiefkühlen", null, -18);
|
||||||
|
|
||||||
|
var result = ProductionStep.create(draft);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue().temperatureCelsius()).isEqualTo(-18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Equality")
|
||||||
|
class Equality {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should be equal when same ID")
|
||||||
|
void should_BeEqual_When_SameId() {
|
||||||
|
var step = ProductionStep.create(
|
||||||
|
new ProductionStepDraft(1, "Mischen", 10, null)
|
||||||
|
).unsafeGetValue();
|
||||||
|
|
||||||
|
var reconstituted = ProductionStep.reconstitute(
|
||||||
|
step.id(), 2, "Other description", 20, 80
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(step).isEqualTo(reconstituted);
|
||||||
|
assertThat(step.hashCode()).isEqualTo(reconstituted.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should not be equal when different ID")
|
||||||
|
void should_NotBeEqual_When_DifferentId() {
|
||||||
|
var step1 = ProductionStep.create(
|
||||||
|
new ProductionStepDraft(1, "Mischen", null, null)
|
||||||
|
).unsafeGetValue();
|
||||||
|
var step2 = ProductionStep.create(
|
||||||
|
new ProductionStepDraft(1, "Mischen", null, null)
|
||||||
|
).unsafeGetValue();
|
||||||
|
|
||||||
|
assertThat(step1).isNotEqualTo(step2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ class RecipeTest {
|
||||||
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.ingredients()).isEmpty();
|
||||||
|
assertThat(recipe.productionSteps()).isEmpty();
|
||||||
assertThat(recipe.createdAt()).isNotNull();
|
assertThat(recipe.createdAt()).isNotNull();
|
||||||
assertThat(recipe.updatedAt()).isNotNull();
|
assertThat(recipe.updatedAt()).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +236,7 @@ class RecipeTest {
|
||||||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||||
null, new YieldPercentage(85), 14,
|
null, new YieldPercentage(85), 14,
|
||||||
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||||
RecipeStatus.ACTIVE, List.of(),
|
RecipeStatus.ACTIVE, List.of(), List.of(),
|
||||||
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -318,7 +319,7 @@ class RecipeTest {
|
||||||
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
RecipeId.generate(), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT,
|
||||||
null, new YieldPercentage(85), 14,
|
null, new YieldPercentage(85), 14,
|
||||||
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
Quantity.of(new java.math.BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||||
RecipeStatus.ACTIVE, List.of(),
|
RecipeStatus.ACTIVE, List.of(), List.of(),
|
||||||
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -344,6 +345,129 @@ class RecipeTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("addProductionStep()")
|
||||||
|
class AddProductionStep {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should add production step to DRAFT recipe")
|
||||||
|
void should_AddStep_When_DraftStatus() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
|
||||||
|
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Fleisch wolfen", 15, 4));
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
var step = result.unsafeGetValue();
|
||||||
|
assertThat(step.id()).isNotNull();
|
||||||
|
assertThat(step.stepNumber()).isEqualTo(1);
|
||||||
|
assertThat(step.description()).isEqualTo("Fleisch wolfen");
|
||||||
|
assertThat(step.durationMinutes()).isEqualTo(15);
|
||||||
|
assertThat(step.temperatureCelsius()).isEqualTo(4);
|
||||||
|
assertThat(recipe.productionSteps()).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(), List.of(),
|
||||||
|
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Mischen", null, null));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when step number is duplicate")
|
||||||
|
void should_Fail_When_DuplicateStepNumber() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(1, "Fleisch wolfen", null, null));
|
||||||
|
|
||||||
|
var result = recipe.addProductionStep(new ProductionStepDraft(1, "Nochmal wolfen", null, null));
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.DuplicateStepNumber.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should allow multiple steps with different numbers")
|
||||||
|
void should_AllowMultipleSteps_WithDifferentNumbers() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", 15, null));
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(2, "Mischen", 10, null));
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(3, "Füllen", 20, null));
|
||||||
|
|
||||||
|
assertThat(recipe.productionSteps()).hasSize(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("removeProductionStep()")
|
||||||
|
class RemoveProductionStep {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should remove existing step by step number")
|
||||||
|
void should_RemoveStep_When_Exists() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", null, null));
|
||||||
|
|
||||||
|
var result = recipe.removeProductionStep(1);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(recipe.productionSteps()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when step not found")
|
||||||
|
void should_Fail_When_StepNotFound() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
|
||||||
|
var result = recipe.removeProductionStep(99);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.StepNotFound.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(), List.of(),
|
||||||
|
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = recipe.removeProductionStep(1);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NotInDraftStatus.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should allow gaps in step numbers after removal")
|
||||||
|
void should_AllowStepNumberGaps_AfterRemoval() {
|
||||||
|
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(1, "Wolfen", null, null));
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(2, "Mischen", null, null));
|
||||||
|
recipe.addProductionStep(new ProductionStepDraft(3, "Füllen", null, null));
|
||||||
|
|
||||||
|
recipe.removeProductionStep(2);
|
||||||
|
|
||||||
|
assertThat(recipe.productionSteps()).hasSize(2);
|
||||||
|
assertThat(recipe.productionSteps().stream().map(s -> s.stepNumber()).toList())
|
||||||
|
.containsExactly(1, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("Equality")
|
@DisplayName("Equality")
|
||||||
class Equality {
|
class Equality {
|
||||||
|
|
@ -356,7 +480,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, List.of(), recipe1.createdAt(), recipe1.updatedAt()
|
RecipeStatus.ACTIVE, List.of(), List.of(), recipe1.createdAt(), recipe1.updatedAt()
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(recipe1).isEqualTo(recipe2);
|
assertThat(recipe1).isEqualTo(recipe2);
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -356,6 +356,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/recipes/{id}/steps": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["addProductionStep"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/recipes/{id}/ingredients": {
|
"/api/recipes/{id}/ingredients": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -680,6 +696,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/recipes/{id}/steps/{stepNumber}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["removeProductionStep"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/recipes/{id}/ingredients/{ingredientId}": {
|
"/api/recipes/{id}/ingredients/{ingredientId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1070,6 +1102,16 @@ export interface components {
|
||||||
subRecipeId?: string | null;
|
subRecipeId?: string | null;
|
||||||
substitutable: boolean;
|
substitutable: boolean;
|
||||||
};
|
};
|
||||||
|
ProductionStepResponse: {
|
||||||
|
id: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
stepNumber: number;
|
||||||
|
description: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
durationMinutes?: number | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
temperatureCelsius?: number | null;
|
||||||
|
};
|
||||||
RecipeResponse: {
|
RecipeResponse: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1085,11 +1127,21 @@ export interface components {
|
||||||
outputUom: string;
|
outputUom: string;
|
||||||
status: string;
|
status: string;
|
||||||
ingredients: components["schemas"]["IngredientResponse"][];
|
ingredients: components["schemas"]["IngredientResponse"][];
|
||||||
|
productionSteps: components["schemas"]["ProductionStepResponse"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
AddProductionStepRequest: {
|
||||||
|
/** Format: int32 */
|
||||||
|
stepNumber?: number;
|
||||||
|
description: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
durationMinutes?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
temperatureCelsius?: number;
|
||||||
|
};
|
||||||
AddRecipeIngredientRequest: {
|
AddRecipeIngredientRequest: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
position?: number;
|
position?: number;
|
||||||
|
|
@ -2076,6 +2128,32 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
addProductionStep: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AddProductionStepRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["RecipeResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
addIngredient: {
|
addIngredient: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -2672,6 +2750,27 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
removeProductionStep: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
stepNumber: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
removeIngredient: {
|
removeIngredient: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue