mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
feat(production): Rezept aktivieren (#29)
Rezepte können vom DRAFT- in den ACTIVE-Status überführt werden.
Voraussetzung: mindestens eine Zutat muss vorhanden sein.
Inkl. Use Case, REST-Endpoint POST /recipes/{id}/activate,
Domain-Tests und Error Handling.
This commit is contained in:
parent
cf93b847e5
commit
a132211a74
11 changed files with 229 additions and 3 deletions
|
|
@ -0,0 +1,53 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.ActivateRecipeCommand;
|
||||
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 ActivateRecipe {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public ActivateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<RecipeError, Recipe> execute(ActivateRecipeCommand 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.activate()) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
switch (recipeRepository.save(recipe)) {
|
||||
case Result.Failure(var err) ->
|
||||
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
return Result.success(recipe);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record ActivateRecipeCommand(String recipeId) {
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ import static de.effigenix.shared.common.Result.*;
|
|||
* 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
|
||||
* 11. Recipe can only be activated when it has at least one ingredient
|
||||
* 12. Recipe can only be activated from DRAFT status
|
||||
*/
|
||||
public class Recipe {
|
||||
|
||||
|
|
@ -214,6 +216,20 @@ public class Recipe {
|
|||
return Result.success(null);
|
||||
}
|
||||
|
||||
// ==================== Status Transitions ====================
|
||||
|
||||
public Result<RecipeError, Void> activate() {
|
||||
if (status != RecipeStatus.DRAFT) {
|
||||
return Result.failure(new RecipeError.InvalidStatusTransition(status, RecipeStatus.ACTIVE));
|
||||
}
|
||||
if (ingredients.isEmpty()) {
|
||||
return Result.failure(new RecipeError.NoIngredients());
|
||||
}
|
||||
this.status = RecipeStatus.ACTIVE;
|
||||
touch();
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
// ==================== Getters ====================
|
||||
|
||||
public RecipeId id() { return id; }
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ public sealed interface RecipeError {
|
|||
@Override public String message() { return "Production step with number " + stepNumber + " not found"; }
|
||||
}
|
||||
|
||||
record NoIngredients() implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_NO_INGREDIENTS"; }
|
||||
@Override public String message() { return "Recipe must have at least one ingredient to be activated"; }
|
||||
}
|
||||
|
||||
record InvalidStatusTransition(RecipeStatus current, RecipeStatus target) implements RecipeError {
|
||||
@Override public String code() { return "RECIPE_INVALID_STATUS_TRANSITION"; }
|
||||
@Override public String message() { return "Cannot transition from " + current + " to " + target; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements RecipeError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.production.ActivateRecipe;
|
||||
import de.effigenix.application.production.AddProductionStep;
|
||||
import de.effigenix.application.production.AddRecipeIngredient;
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
|
|
@ -37,4 +38,9 @@ public class ProductionUseCaseConfiguration {
|
|||
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new RemoveProductionStep(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new ActivateRecipe(recipeRepository, authorizationPort);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package de.effigenix.infrastructure.production.web.controller;
|
||||
|
||||
import de.effigenix.application.production.ActivateRecipe;
|
||||
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.ActivateRecipeCommand;
|
||||
import de.effigenix.application.production.command.AddProductionStepCommand;
|
||||
import de.effigenix.application.production.command.AddRecipeIngredientCommand;
|
||||
import de.effigenix.application.production.command.CreateRecipeCommand;
|
||||
|
|
@ -40,17 +42,20 @@ public class RecipeController {
|
|||
private final RemoveRecipeIngredient removeRecipeIngredient;
|
||||
private final AddProductionStep addProductionStep;
|
||||
private final RemoveProductionStep removeProductionStep;
|
||||
private final ActivateRecipe activateRecipe;
|
||||
|
||||
public RecipeController(CreateRecipe createRecipe,
|
||||
AddRecipeIngredient addRecipeIngredient,
|
||||
RemoveRecipeIngredient removeRecipeIngredient,
|
||||
AddProductionStep addProductionStep,
|
||||
RemoveProductionStep removeProductionStep) {
|
||||
RemoveProductionStep removeProductionStep,
|
||||
ActivateRecipe activateRecipe) {
|
||||
this.createRecipe = createRecipe;
|
||||
this.addRecipeIngredient = addRecipeIngredient;
|
||||
this.removeRecipeIngredient = removeRecipeIngredient;
|
||||
this.addProductionStep = addProductionStep;
|
||||
this.removeProductionStep = removeProductionStep;
|
||||
this.activateRecipe = activateRecipe;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
|
@ -167,6 +172,26 @@ public class RecipeController {
|
|||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/activate")
|
||||
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
|
||||
public ResponseEntity<RecipeResponse> activateRecipe(
|
||||
@PathVariable("id") String recipeId,
|
||||
Authentication authentication
|
||||
) {
|
||||
var actorId = extractActorId(authentication);
|
||||
logger.info("Activating recipe: {} by actor: {}", recipeId, actorId.value());
|
||||
|
||||
var cmd = new ActivateRecipeCommand(recipeId);
|
||||
var result = activateRecipe.execute(cmd, actorId);
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new RecipeDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
logger.info("Recipe activated: {}", recipeId);
|
||||
return ResponseEntity.ok(RecipeResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
private ActorId extractActorId(Authentication authentication) {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new IllegalStateException("No authentication found in SecurityContext");
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ public final class ProductionErrorHttpStatusMapper {
|
|||
case RecipeError.InvalidShelfLife e -> 400;
|
||||
case RecipeError.ValidationFailure e -> 400;
|
||||
case RecipeError.NotInDraftStatus e -> 409;
|
||||
case RecipeError.InvalidStatusTransition e -> 409;
|
||||
case RecipeError.NoIngredients e -> 400;
|
||||
case RecipeError.Unauthorized e -> 403;
|
||||
case RecipeError.RepositoryFailure e -> 500;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -468,6 +468,78 @@ class RecipeTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("activate()")
|
||||
class Activate {
|
||||
|
||||
@Test
|
||||
@DisplayName("should activate DRAFT recipe with ingredients")
|
||||
void should_Activate_When_DraftWithIngredients() {
|
||||
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||
recipe.addIngredient(validIngredientDraft(1));
|
||||
var updatedBefore = recipe.updatedAt();
|
||||
|
||||
var result = recipe.activate();
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(recipe.status()).isEqualTo(RecipeStatus.ACTIVE);
|
||||
assertThat(recipe.updatedAt()).isAfter(updatedBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when DRAFT recipe has no ingredients")
|
||||
void should_Fail_When_NoIngredients() {
|
||||
var recipe = Recipe.create(validDraft()).unsafeGetValue();
|
||||
|
||||
var result = recipe.activate();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.NoIngredients.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipe is already ACTIVE")
|
||||
void should_Fail_When_AlreadyActive() {
|
||||
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.activate();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
var error = result.unsafeGetError();
|
||||
assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class);
|
||||
var transition = (RecipeError.InvalidStatusTransition) error;
|
||||
assertThat(transition.current()).isEqualTo(RecipeStatus.ACTIVE);
|
||||
assertThat(transition.target()).isEqualTo(RecipeStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipe is ARCHIVED")
|
||||
void should_Fail_When_Archived() {
|
||||
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.ARCHIVED, List.of(), List.of(),
|
||||
java.time.LocalDateTime.now(), java.time.LocalDateTime.now()
|
||||
);
|
||||
|
||||
var result = recipe.activate();
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
var error = result.unsafeGetError();
|
||||
assertThat(error).isInstanceOf(RecipeError.InvalidStatusTransition.class);
|
||||
var transition = (RecipeError.InvalidStatusTransition) error;
|
||||
assertThat(transition.current()).isEqualTo(RecipeStatus.ARCHIVED);
|
||||
assertThat(transition.target()).isEqualTo(RecipeStatus.ACTIVE);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Equality")
|
||||
class Equality {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -388,6 +388,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/recipes/{id}/activate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["activateRecipe"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/inventory/storage-locations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -2180,6 +2196,28 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
activateRecipe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["RecipeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listStorageLocations: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ if curl -sf "$API_DOCS_URL" -o /dev/null 2>/dev/null; then
|
|||
echo "✓ Backend läuft bereits"
|
||||
else
|
||||
echo "→ Backend wird gestartet..."
|
||||
mvn -f "$BACKEND_DIR/pom.xml" spring-boot:run -q &
|
||||
mvn -f "$BACKEND_DIR/pom.xml" spring-boot:run -Pno-db -q &
|
||||
BACKEND_PID=$!
|
||||
wait_for_backend
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue