mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:30:16 +01:00
feat(production): Zyklus-Erkennung bei verschachtelten Rezepten (#32)
Verhindert zirkuläre Abhängigkeiten (A→B→A, A→B→C→A) beim Hinzufügen von Sub-Rezepten als Zutaten. Iterative DFS-Prüfung mit Pfad-Tracking für aussagekräftige Fehlermeldungen.
This commit is contained in:
parent
05147227d1
commit
8a9d2bfc30
7 changed files with 415 additions and 3 deletions
|
|
@ -12,10 +12,13 @@ public class AddRecipeIngredient {
|
|||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
private final RecipeCycleChecker cycleChecker;
|
||||
|
||||
public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort,
|
||||
RecipeCycleChecker cycleChecker) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
this.cycleChecker = cycleChecker;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) {
|
||||
|
|
@ -37,6 +40,13 @@ public class AddRecipeIngredient {
|
|||
}
|
||||
}
|
||||
|
||||
if (cmd.subRecipeId() != null) {
|
||||
switch (cycleChecker.check(cmd.recipeId(), cmd.subRecipeId())) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
}
|
||||
|
||||
var draft = new IngredientDraft(
|
||||
cmd.position(), cmd.articleId(), cmd.quantity(),
|
||||
cmd.uom(), cmd.subRecipeId(), cmd.substitutable()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class RecipeCycleChecker {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
|
||||
public RecipeCycleChecker(RecipeRepository recipeRepository) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Void> check(String parentRecipeId, String subRecipeId) {
|
||||
if (parentRecipeId.equals(subRecipeId)) {
|
||||
return Result.failure(new RecipeError.CyclicDependencyDetected(
|
||||
List.of(parentRecipeId, subRecipeId)));
|
||||
}
|
||||
|
||||
var visited = new HashSet<String>();
|
||||
var stack = new ArrayDeque<List<String>>();
|
||||
stack.push(List.of(parentRecipeId, subRecipeId));
|
||||
|
||||
while (!stack.isEmpty()) {
|
||||
var path = stack.pop();
|
||||
var currentId = path.get(path.size() - 1);
|
||||
|
||||
if (!visited.add(currentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Recipe recipe;
|
||||
switch (recipeRepository.findById(RecipeId.of(currentId))) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var opt) -> {
|
||||
if (opt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
recipe = opt.get();
|
||||
}
|
||||
}
|
||||
|
||||
for (var ingredient : recipe.ingredients()) {
|
||||
var childSubRecipeId = ingredient.subRecipeId();
|
||||
if (childSubRecipeId == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (childSubRecipeId.equals(parentRecipeId)) {
|
||||
var cyclePath = new ArrayList<>(path);
|
||||
cyclePath.add(childSubRecipeId);
|
||||
return Result.failure(new RecipeError.CyclicDependencyDetected(cyclePath));
|
||||
}
|
||||
|
||||
if (!visited.contains(childSubRecipeId)) {
|
||||
var newPath = new ArrayList<>(path);
|
||||
newPath.add(childSubRecipeId);
|
||||
stack.push(newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public sealed interface RecipeError {
|
||||
|
||||
String code();
|
||||
|
|
@ -63,6 +65,11 @@ public sealed interface RecipeError {
|
|||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
||||
record CyclicDependencyDetected(List<String> cyclePath) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_CYCLIC_DEPENDENCY"; }
|
||||
@Override public String message() { return "Cyclic dependency detected: " + String.join(" → ", cyclePath); }
|
||||
}
|
||||
|
||||
record RepositoryFailure(String message) implements RecipeError {
|
||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import de.effigenix.application.production.ArchiveRecipe;
|
|||
import de.effigenix.application.production.AddProductionStep;
|
||||
import de.effigenix.application.production.AddRecipeIngredient;
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.application.production.RecipeCycleChecker;
|
||||
import de.effigenix.application.production.GetRecipe;
|
||||
import de.effigenix.application.production.ListRecipes;
|
||||
import de.effigenix.application.production.RemoveProductionStep;
|
||||
|
|
@ -23,8 +24,14 @@ public class ProductionUseCaseConfiguration {
|
|||
}
|
||||
|
||||
@Bean
|
||||
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new AddRecipeIngredient(recipeRepository, authorizationPort);
|
||||
public RecipeCycleChecker recipeCycleChecker(RecipeRepository recipeRepository) {
|
||||
return new RecipeCycleChecker(recipeRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort,
|
||||
RecipeCycleChecker recipeCycleChecker) {
|
||||
return new AddRecipeIngredient(recipeRepository, authorizationPort, recipeCycleChecker);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public final class ProductionErrorHttpStatusMapper {
|
|||
case RecipeError.NotInDraftStatus e -> 409;
|
||||
case RecipeError.InvalidStatusTransition e -> 409;
|
||||
case RecipeError.NoIngredients e -> 400;
|
||||
case RecipeError.CyclicDependencyDetected e -> 400;
|
||||
case RecipeError.Unauthorized e -> 403;
|
||||
case RecipeError.RepositoryFailure e -> 500;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue