mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 11:59: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
|
* 8. Ingredient positions must be unique within a recipe
|
||||||
* 9. ProductionSteps can only be added/removed in DRAFT status
|
* 9. ProductionSteps can only be added/removed in DRAFT status
|
||||||
* 10. Step numbers must be unique within a recipe
|
* 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 {
|
public class Recipe {
|
||||||
|
|
||||||
|
|
@ -214,6 +216,20 @@ public class Recipe {
|
||||||
return Result.success(null);
|
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 ====================
|
// ==================== Getters ====================
|
||||||
|
|
||||||
public RecipeId id() { return id; }
|
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"; }
|
@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 {
|
record Unauthorized(String message) implements RecipeError {
|
||||||
@Override public String code() { return "UNAUTHORIZED"; }
|
@Override public String code() { return "UNAUTHORIZED"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.effigenix.infrastructure.config;
|
package de.effigenix.infrastructure.config;
|
||||||
|
|
||||||
|
import de.effigenix.application.production.ActivateRecipe;
|
||||||
import de.effigenix.application.production.AddProductionStep;
|
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;
|
||||||
|
|
@ -37,4 +38,9 @@ public class ProductionUseCaseConfiguration {
|
||||||
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||||
return new RemoveProductionStep(recipeRepository, 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;
|
package de.effigenix.infrastructure.production.web.controller;
|
||||||
|
|
||||||
|
import de.effigenix.application.production.ActivateRecipe;
|
||||||
import de.effigenix.application.production.AddProductionStep;
|
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.RemoveProductionStep;
|
||||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
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.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;
|
||||||
|
|
@ -40,17 +42,20 @@ public class RecipeController {
|
||||||
private final RemoveRecipeIngredient removeRecipeIngredient;
|
private final RemoveRecipeIngredient removeRecipeIngredient;
|
||||||
private final AddProductionStep addProductionStep;
|
private final AddProductionStep addProductionStep;
|
||||||
private final RemoveProductionStep removeProductionStep;
|
private final RemoveProductionStep removeProductionStep;
|
||||||
|
private final ActivateRecipe activateRecipe;
|
||||||
|
|
||||||
public RecipeController(CreateRecipe createRecipe,
|
public RecipeController(CreateRecipe createRecipe,
|
||||||
AddRecipeIngredient addRecipeIngredient,
|
AddRecipeIngredient addRecipeIngredient,
|
||||||
RemoveRecipeIngredient removeRecipeIngredient,
|
RemoveRecipeIngredient removeRecipeIngredient,
|
||||||
AddProductionStep addProductionStep,
|
AddProductionStep addProductionStep,
|
||||||
RemoveProductionStep removeProductionStep) {
|
RemoveProductionStep removeProductionStep,
|
||||||
|
ActivateRecipe activateRecipe) {
|
||||||
this.createRecipe = createRecipe;
|
this.createRecipe = createRecipe;
|
||||||
this.addRecipeIngredient = addRecipeIngredient;
|
this.addRecipeIngredient = addRecipeIngredient;
|
||||||
this.removeRecipeIngredient = removeRecipeIngredient;
|
this.removeRecipeIngredient = removeRecipeIngredient;
|
||||||
this.addProductionStep = addProductionStep;
|
this.addProductionStep = addProductionStep;
|
||||||
this.removeProductionStep = removeProductionStep;
|
this.removeProductionStep = removeProductionStep;
|
||||||
|
this.activateRecipe = activateRecipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -167,6 +172,26 @@ public class RecipeController {
|
||||||
return ResponseEntity.noContent().build();
|
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) {
|
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");
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ public final class ProductionErrorHttpStatusMapper {
|
||||||
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;
|
||||||
|
case RecipeError.InvalidStatusTransition e -> 409;
|
||||||
|
case RecipeError.NoIngredients e -> 400;
|
||||||
case RecipeError.Unauthorized e -> 403;
|
case RecipeError.Unauthorized e -> 403;
|
||||||
case RecipeError.RepositoryFailure e -> 500;
|
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
|
@Nested
|
||||||
@DisplayName("Equality")
|
@DisplayName("Equality")
|
||||||
class Equality {
|
class Equality {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -388,6 +388,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/inventory/storage-locations": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
listStorageLocations: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ if curl -sf "$API_DOCS_URL" -o /dev/null 2>/dev/null; then
|
||||||
echo "✓ Backend läuft bereits"
|
echo "✓ Backend läuft bereits"
|
||||||
else
|
else
|
||||||
echo "→ Backend wird gestartet..."
|
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=$!
|
BACKEND_PID=$!
|
||||||
wait_for_backend
|
wait_for_backend
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue