From cf93b847e5670b46eae50bf668521a129bffee70 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 17:37:18 +0100 Subject: [PATCH] feat(production): Produktionsschritte zum Rezept verwalten + AuthorizationPort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../production/AddProductionStep.java | 58 ++++++ .../production/AddRecipeIngredient.java | 9 +- .../application/production/CreateRecipe.java | 9 +- .../production/RemoveProductionStep.java | 53 ++++++ .../production/RemoveRecipeIngredient.java | 9 +- .../command/AddProductionStepCommand.java | 9 + .../command/RemoveProductionStepCommand.java | 6 + .../domain/production/ProductionStep.java | 80 ++++++++ .../production/ProductionStepDraft.java | 17 ++ .../domain/production/ProductionStepId.java | 20 ++ .../effigenix/domain/production/Recipe.java | 48 ++++- .../domain/production/RecipeError.java | 12 +- .../ProductionUseCaseConfiguration.java | 25 ++- .../entity/ProductionStepEntity.java | 55 ++++++ .../persistence/entity/RecipeEntity.java | 6 + .../persistence/mapper/RecipeMapper.java | 32 ++++ .../web/controller/RecipeController.java | 58 +++++- .../web/dto/AddProductionStepRequest.java | 13 ++ .../web/dto/ProductionStepResponse.java | 23 +++ .../production/web/dto/RecipeResponse.java | 4 +- .../ProductionErrorHttpStatusMapper.java | 2 + ...2-create-recipe-production-steps-table.xml | 39 ++++ .../db/changelog/db.changelog-master.xml | 1 + .../domain/production/ProductionStepTest.java | 177 ++++++++++++++++++ .../domain/production/RecipeTest.java | 130 ++++++++++++- frontend/openapi.json | 2 +- frontend/packages/types/src/generated/api.ts | 99 ++++++++++ 27 files changed, 978 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/production/AddProductionStep.java create mode 100644 backend/src/main/java/de/effigenix/application/production/RemoveProductionStep.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/AddProductionStepCommand.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/RemoveProductionStepCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionStep.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionStepDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionStepId.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionStepEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddProductionStepRequest.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionStepResponse.java create mode 100644 backend/src/main/resources/db/changelog/changes/012-create-recipe-production-steps-table.xml create mode 100644 backend/src/test/java/de/effigenix/domain/production/ProductionStepTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/AddProductionStep.java b/backend/src/main/java/de/effigenix/application/production/AddProductionStep.java new file mode 100644 index 0000000..4df15e1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/AddProductionStep.java @@ -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 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); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java b/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java index 3b2876e..0725fff 100644 --- a/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java +++ b/backend/src/main/java/de/effigenix/application/production/AddRecipeIngredient.java @@ -4,18 +4,25 @@ import de.effigenix.application.production.command.AddRecipeIngredientCommand; import de.effigenix.domain.production.*; import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.springframework.transaction.annotation.Transactional; @Transactional public class AddRecipeIngredient { private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; - public AddRecipeIngredient(RecipeRepository recipeRepository) { + public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; } public Result execute(AddRecipeIngredientCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) { + return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes")); + } + var recipeId = RecipeId.of(cmd.recipeId()); Recipe recipe; diff --git a/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java b/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java index 592530d..a84740b 100644 --- a/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java +++ b/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java @@ -4,18 +4,25 @@ import de.effigenix.application.production.command.CreateRecipeCommand; import de.effigenix.domain.production.*; import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.springframework.transaction.annotation.Transactional; @Transactional public class CreateRecipe { private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; - public CreateRecipe(RecipeRepository recipeRepository) { + public CreateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; } public Result execute(CreateRecipeCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) { + return Result.failure(new RecipeError.Unauthorized("Not authorized to create recipes")); + } + var draft = new RecipeDraft( cmd.name(), cmd.version(), cmd.type(), cmd.description(), cmd.yieldPercentage(), cmd.shelfLifeDays(), diff --git a/backend/src/main/java/de/effigenix/application/production/RemoveProductionStep.java b/backend/src/main/java/de/effigenix/application/production/RemoveProductionStep.java new file mode 100644 index 0000000..06a2f19 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/RemoveProductionStep.java @@ -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 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); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java b/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java index e3773ed..9190d5b 100644 --- a/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java +++ b/backend/src/main/java/de/effigenix/application/production/RemoveRecipeIngredient.java @@ -4,18 +4,25 @@ import de.effigenix.application.production.command.RemoveRecipeIngredientCommand import de.effigenix.domain.production.*; import de.effigenix.shared.common.Result; import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; import org.springframework.transaction.annotation.Transactional; @Transactional public class RemoveRecipeIngredient { private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; - public RemoveRecipeIngredient(RecipeRepository recipeRepository) { + public RemoveRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; } public Result execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) { + return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes")); + } + var recipeId = RecipeId.of(cmd.recipeId()); Recipe recipe; diff --git a/backend/src/main/java/de/effigenix/application/production/command/AddProductionStepCommand.java b/backend/src/main/java/de/effigenix/application/production/command/AddProductionStepCommand.java new file mode 100644 index 0000000..9294a7c --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/AddProductionStepCommand.java @@ -0,0 +1,9 @@ +package de.effigenix.application.production.command; + +public record AddProductionStepCommand( + String recipeId, + int stepNumber, + String description, + Integer durationMinutes, + Integer temperatureCelsius +) {} diff --git a/backend/src/main/java/de/effigenix/application/production/command/RemoveProductionStepCommand.java b/backend/src/main/java/de/effigenix/application/production/command/RemoveProductionStepCommand.java new file mode 100644 index 0000000..8cbe15e --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/RemoveProductionStepCommand.java @@ -0,0 +1,6 @@ +package de.effigenix.application.production.command; + +public record RemoveProductionStepCommand( + String recipeId, + int stepNumber +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionStep.java b/backend/src/main/java/de/effigenix/domain/production/ProductionStep.java new file mode 100644 index 0000000..dd214b0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionStep.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionStepDraft.java b/backend/src/main/java/de/effigenix/domain/production/ProductionStepDraft.java new file mode 100644 index 0000000..81a61c3 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionStepDraft.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionStepId.java b/backend/src/main/java/de/effigenix/domain/production/ProductionStepId.java new file mode 100644 index 0000000..a2fc313 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionStepId.java @@ -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); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/Recipe.java b/backend/src/main/java/de/effigenix/domain/production/Recipe.java index db7a268..dacf01e 100644 --- a/backend/src/main/java/de/effigenix/domain/production/Recipe.java +++ b/backend/src/main/java/de/effigenix/domain/production/Recipe.java @@ -23,6 +23,8 @@ import static de.effigenix.shared.common.Result.*; * 6. New recipes always start in DRAFT status * 7. Ingredients can only be added/removed in DRAFT status * 8. Ingredient positions must be unique within a recipe + * 9. ProductionSteps can only be added/removed in DRAFT status + * 10. Step numbers must be unique within a recipe */ public class Recipe { @@ -36,6 +38,7 @@ public class Recipe { private Quantity outputQuantity; private RecipeStatus status; private final List ingredients; + private final List productionSteps; private final LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -50,6 +53,7 @@ public class Recipe { Quantity outputQuantity, RecipeStatus status, List ingredients, + List productionSteps, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -63,6 +67,7 @@ public class Recipe { this.outputQuantity = outputQuantity; this.status = status; this.ingredients = new ArrayList<>(ingredients); + this.productionSteps = new ArrayList<>(productionSteps); this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -117,7 +122,7 @@ public class Recipe { return Result.success(new Recipe( RecipeId.generate(), name, draft.version(), draft.type(), draft.description(), yieldPercentage, shelfLifeDays, outputQuantity, - RecipeStatus.DRAFT, List.of(), now, now + RecipeStatus.DRAFT, List.of(), List.of(), now, now )); } @@ -135,11 +140,12 @@ public class Recipe { Quantity outputQuantity, RecipeStatus status, List ingredients, + List productionSteps, LocalDateTime createdAt, LocalDateTime updatedAt ) { return new Recipe(id, name, version, type, description, - yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, createdAt, updatedAt); + yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt); } // ==================== Ingredient Management ==================== @@ -175,6 +181,39 @@ public class Recipe { return Result.success(null); } + // ==================== ProductionStep Management ==================== + + public Result 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 removeProductionStep(int stepNumber) { + if (status != RecipeStatus.DRAFT) { + return Result.failure(new RecipeError.NotInDraftStatus()); + } + boolean removed = this.productionSteps.removeIf(s -> s.stepNumber() == stepNumber); + if (!removed) { + return Result.failure(new RecipeError.StepNotFound(stepNumber)); + } + touch(); + return Result.success(null); + } + // ==================== Getters ==================== public RecipeId id() { return id; } @@ -187,6 +226,7 @@ public class Recipe { public Quantity outputQuantity() { return outputQuantity; } public RecipeStatus status() { return status; } public List ingredients() { return Collections.unmodifiableList(ingredients); } + public List productionSteps() { return Collections.unmodifiableList(productionSteps); } public LocalDateTime createdAt() { return createdAt; } public LocalDateTime updatedAt() { return updatedAt; } @@ -196,6 +236,10 @@ public class Recipe { return ingredients.stream().anyMatch(i -> i.position() == position); } + private boolean hasStepNumber(int stepNumber) { + return productionSteps.stream().anyMatch(s -> s.stepNumber() == stepNumber); + } + private void touch() { this.updatedAt = LocalDateTime.now(); } diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeError.java b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java index dca6022..f4536dd 100644 --- a/backend/src/main/java/de/effigenix/domain/production/RecipeError.java +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeError.java @@ -35,10 +35,20 @@ public sealed interface RecipeError { } record IngredientNotFound(IngredientId id) implements RecipeError { - @Override public String code() { return "INGREDIENT_NOT_FOUND"; } + @Override public String code() { return "RECIPE_INGREDIENT_NOT_FOUND"; } @Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; } } + record DuplicateStepNumber(int stepNumber) implements RecipeError { + @Override public String code() { return "RECIPE_DUPLICATE_STEP_NUMBER"; } + @Override public String message() { return "Step number " + stepNumber + " is already taken"; } + } + + record StepNotFound(int stepNumber) implements RecipeError { + @Override public String code() { return "RECIPE_STEP_NOT_FOUND"; } + @Override public String message() { return "Production step with number " + stepNumber + " not found"; } + } + record Unauthorized(String message) implements RecipeError { @Override public String code() { return "UNAUTHORIZED"; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index ab5d5fb..bc509cb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -1,9 +1,12 @@ package de.effigenix.infrastructure.config; +import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.RemoveProductionStep; import de.effigenix.application.production.RemoveRecipeIngredient; import de.effigenix.domain.production.RecipeRepository; +import de.effigenix.shared.security.AuthorizationPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,17 +14,27 @@ import org.springframework.context.annotation.Configuration; public class ProductionUseCaseConfiguration { @Bean - public CreateRecipe createRecipe(RecipeRepository recipeRepository) { - return new CreateRecipe(recipeRepository); + public CreateRecipe createRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { + return new CreateRecipe(recipeRepository, authorizationPort); } @Bean - public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) { - return new AddRecipeIngredient(recipeRepository); + public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { + return new AddRecipeIngredient(recipeRepository, authorizationPort); } @Bean - public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) { - return new RemoveRecipeIngredient(recipeRepository); + public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { + return new RemoveRecipeIngredient(recipeRepository, authorizationPort); + } + + @Bean + public AddProductionStep addProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { + return new AddProductionStep(recipeRepository, authorizationPort); + } + + @Bean + public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) { + return new RemoveProductionStep(recipeRepository, authorizationPort); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionStepEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionStepEntity.java new file mode 100644 index 0000000..25e7d4e --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionStepEntity.java @@ -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; } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java index 4d8481e..35dd0b9 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/RecipeEntity.java @@ -53,6 +53,10 @@ public class RecipeEntity { @OrderBy("position ASC") private List ingredients = new ArrayList<>(); + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + @OrderBy("stepNumber ASC") + private List productionSteps = new ArrayList<>(); + protected RecipeEntity() {} public RecipeEntity(String id, String name, int version, String type, String description, @@ -85,6 +89,7 @@ public class RecipeEntity { public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public List getIngredients() { return ingredients; } + public List getProductionSteps() { return productionSteps; } public void setId(String id) { this.id = id; } public void setName(String name) { this.name = name; } @@ -99,4 +104,5 @@ public class RecipeEntity { public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public void setIngredients(List ingredients) { this.ingredients = ingredients; } + public void setProductionSteps(List productionSteps) { this.productionSteps = productionSteps; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java index 0c8fdd7..016b828 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/RecipeMapper.java @@ -2,6 +2,7 @@ package de.effigenix.infrastructure.production.persistence.mapper; import de.effigenix.domain.production.*; import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity; +import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity; import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity; import org.springframework.stereotype.Component; @@ -32,6 +33,11 @@ public class RecipeMapper { .collect(Collectors.toList()); entity.setIngredients(ingredientEntities); + List stepEntities = recipe.productionSteps().stream() + .map(s -> toProductionStepEntity(s, entity)) + .collect(Collectors.toList()); + entity.setProductionSteps(stepEntities); + return entity; } @@ -40,6 +46,10 @@ public class RecipeMapper { .map(this::toDomainIngredient) .collect(Collectors.toList()); + List productionSteps = entity.getProductionSteps().stream() + .map(this::toDomainProductionStep) + .collect(Collectors.toList()); + return Recipe.reconstitute( RecipeId.of(entity.getId()), new RecipeName(entity.getName()), @@ -55,6 +65,7 @@ public class RecipeMapper { ), RecipeStatus.valueOf(entity.getStatus()), ingredients, + productionSteps, entity.getCreatedAt(), entity.getUpdatedAt() ); @@ -73,6 +84,27 @@ public class RecipeMapper { ); } + private ProductionStepEntity toProductionStepEntity(ProductionStep step, RecipeEntity recipe) { + return new ProductionStepEntity( + step.id().value(), + recipe, + step.stepNumber(), + step.description(), + step.durationMinutes(), + step.temperatureCelsius() + ); + } + + private ProductionStep toDomainProductionStep(ProductionStepEntity entity) { + return ProductionStep.reconstitute( + ProductionStepId.of(entity.getId()), + entity.getStepNumber(), + entity.getDescription(), + entity.getDurationMinutes(), + entity.getTemperatureCelsius() + ); + } + private Ingredient toDomainIngredient(IngredientEntity entity) { return Ingredient.reconstitute( IngredientId.of(entity.getId()), diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java index 2a3f247..2ad7752 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/RecipeController.java @@ -1,12 +1,17 @@ package de.effigenix.infrastructure.production.web.controller; +import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateRecipe; +import de.effigenix.application.production.RemoveProductionStep; import de.effigenix.application.production.RemoveRecipeIngredient; +import de.effigenix.application.production.command.AddProductionStepCommand; import de.effigenix.application.production.command.AddRecipeIngredientCommand; import de.effigenix.application.production.command.CreateRecipeCommand; +import de.effigenix.application.production.command.RemoveProductionStepCommand; import de.effigenix.application.production.command.RemoveRecipeIngredientCommand; import de.effigenix.domain.production.RecipeError; +import de.effigenix.infrastructure.production.web.dto.AddProductionStepRequest; import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest; import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest; import de.effigenix.infrastructure.production.web.dto.RecipeResponse; @@ -33,13 +38,19 @@ public class RecipeController { private final CreateRecipe createRecipe; private final AddRecipeIngredient addRecipeIngredient; private final RemoveRecipeIngredient removeRecipeIngredient; + private final AddProductionStep addProductionStep; + private final RemoveProductionStep removeProductionStep; public RecipeController(CreateRecipe createRecipe, AddRecipeIngredient addRecipeIngredient, - RemoveRecipeIngredient removeRecipeIngredient) { + RemoveRecipeIngredient removeRecipeIngredient, + AddProductionStep addProductionStep, + RemoveProductionStep removeProductionStep) { this.createRecipe = createRecipe; this.addRecipeIngredient = addRecipeIngredient; this.removeRecipeIngredient = removeRecipeIngredient; + this.addProductionStep = addProductionStep; + this.removeProductionStep = removeProductionStep; } @PostMapping @@ -111,6 +122,51 @@ public class RecipeController { return ResponseEntity.noContent().build(); } + @PostMapping("/{id}/steps") + @PreAuthorize("hasAuthority('RECIPE_WRITE')") + public ResponseEntity 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 removeProductionStep( + @PathVariable("id") String recipeId, + @PathVariable("stepNumber") int stepNumber, + Authentication authentication + ) { + var actorId = extractActorId(authentication); + logger.info("Removing production step {} from recipe: {} by actor: {}", stepNumber, recipeId, actorId.value()); + + var cmd = new RemoveProductionStepCommand(recipeId, stepNumber); + var result = removeProductionStep.execute(cmd, actorId); + + if (result.isFailure()) { + throw new RecipeDomainErrorException(result.unsafeGetError()); + } + + logger.info("Production step removed from recipe: {}", recipeId); + return ResponseEntity.noContent().build(); + } + private ActorId extractActorId(Authentication authentication) { if (authentication == null || authentication.getName() == null) { throw new IllegalStateException("No authentication found in SecurityContext"); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddProductionStepRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddProductionStepRequest.java new file mode 100644 index 0000000..0e3a8f7 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/AddProductionStepRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionStepResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionStepResponse.java new file mode 100644 index 0000000..daab2ad --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionStepResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java index bf8e649..12a9e4c 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeResponse.java @@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import java.util.List; -@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "createdAt", "updatedAt"}) +@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"}) public record RecipeResponse( String id, String name, @@ -19,6 +19,7 @@ public record RecipeResponse( String outputUom, String status, List ingredients, + List productionSteps, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -35,6 +36,7 @@ public record RecipeResponse( recipe.outputQuantity().uom().name(), recipe.status().name(), recipe.ingredients().stream().map(IngredientResponse::from).toList(), + recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(), recipe.createdAt(), recipe.updatedAt() ); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index ac929ce..1791fa7 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -10,8 +10,10 @@ public final class ProductionErrorHttpStatusMapper { return switch (error) { case RecipeError.RecipeNotFound e -> 404; case RecipeError.IngredientNotFound e -> 404; + case RecipeError.StepNotFound e -> 404; case RecipeError.NameAndVersionAlreadyExists e -> 409; case RecipeError.DuplicatePosition e -> 409; + case RecipeError.DuplicateStepNumber e -> 409; case RecipeError.InvalidShelfLife e -> 400; case RecipeError.ValidationFailure e -> 400; case RecipeError.NotInDraftStatus e -> 409; diff --git a/backend/src/main/resources/db/changelog/changes/012-create-recipe-production-steps-table.xml b/backend/src/main/resources/db/changelog/changes/012-create-recipe-production-steps-table.xml new file mode 100644 index 0000000..42edce0 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/012-create-recipe-production-steps-table.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 032102c..5c78ec1 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -16,5 +16,6 @@ + diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionStepTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionStepTest.java new file mode 100644 index 0000000..a8ce333 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionStepTest.java @@ -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); + } + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java index 2374c5a..b35d30b 100644 --- a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java @@ -45,6 +45,7 @@ class RecipeTest { assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT); assertThat(recipe.ingredients()).isEmpty(); + assertThat(recipe.productionSteps()).isEmpty(); assertThat(recipe.createdAt()).isNotNull(); assertThat(recipe.updatedAt()).isNotNull(); } @@ -235,7 +236,7 @@ class RecipeTest { 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(), + RecipeStatus.ACTIVE, List.of(), List.of(), java.time.LocalDateTime.now(), java.time.LocalDateTime.now() ); @@ -318,7 +319,7 @@ class RecipeTest { 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(), + RecipeStatus.ACTIVE, List.of(), List.of(), 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 @DisplayName("Equality") class Equality { @@ -356,7 +480,7 @@ class RecipeTest { recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL, null, new YieldPercentage(100), null, 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); diff --git a/frontend/openapi.json b/frontend/openapi.json index ea50133..3d8cf22 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.1","info":{"title":"Effigenix Fleischerei ERP API","description":"RESTful API for Effigenix Fleischerei ERP System.\n\n## Authentication\n\nAll endpoints (except /api/auth/login and /api/auth/refresh) require JWT authentication.\n\n1. Login via POST /api/auth/login with username and password\n2. Copy the returned access token\n3. Click \"Authorize\" button (top right)\n4. Enter: Bearer \n5. Click \"Authorize\"\n\n## User Management\n\n- **Authentication**: Login, logout, refresh token\n- **User Management**: Create, update, list users (ADMIN only)\n- **Role Management**: Assign roles, lock/unlock users (ADMIN only)\n- **Password Management**: Change password (requires current password)\n\n## Error Handling\n\nAll errors return a consistent error response format:\n\n```json\n{\n \"code\": \"USER_NOT_FOUND\",\n \"message\": \"User with ID 'user-123' not found\",\n \"status\": 404,\n \"timestamp\": \"2026-02-17T12:00:00\",\n \"path\": \"/api/users/user-123\",\n \"validationErrors\": null\n}\n```\n\n## Architecture\n\nBuilt with:\n- Domain-Driven Design (DDD)\n- Clean Architecture (Hexagonal Architecture)\n- Spring Boot 3.2\n- Java 21\n- PostgreSQL\n","contact":{"name":"Effigenix Development Team","url":"https://effigenix.com","email":"dev@effigenix.com"},"license":{"name":"Proprietary","url":"https://effigenix.com/license"},"version":"0.1.0"},"servers":[{"url":"http://localhost:8080","description":"Local Development Server"},{"url":"https://api.effigenix.com","description":"Production Server"}],"tags":[{"name":"Storage Locations","description":"Storage location management endpoints"},{"name":"Product Categories","description":"Product category management endpoints"},{"name":"User Management","description":"User management endpoints (requires authentication)"},{"name":"Articles","description":"Article management endpoints"},{"name":"Recipes","description":"Recipe management endpoints"},{"name":"Role Management","description":"Role management endpoints (ADMIN only)"},{"name":"Customers","description":"Customer management endpoints"},{"name":"Suppliers","description":"Supplier management endpoints"},{"name":"Authentication","description":"Authentication and session management endpoints"}],"paths":{"/api/users/{id}":{"get":{"tags":["User Management"],"summary":"Get user by ID","description":"Retrieve a single user by their ID.","operationId":"getUserById","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["User Management"],"summary":"Update user","description":"Update user details (email, branchId).","operationId":"updateUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"User updated successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Email already exists","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/password":{"put":{"tags":["User Management"],"summary":"Change password","description":"Change user password. Requires current password for verification.","operationId":"changePassword","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordRequest"}}},"required":true},"responses":{"400":{"description":"Invalid password"},"401":{"description":"Invalid current password"},"404":{"description":"User not found"},"204":{"description":"Password changed successfully"}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}":{"get":{"tags":["Suppliers"],"operationId":"getSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Suppliers"],"operationId":"updateSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}":{"put":{"tags":["Storage Locations"],"operationId":"updateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateStorageLocationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}":{"get":{"tags":["Customers"],"operationId":"getCustomer","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Customers"],"operationId":"updateCustomer","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCustomerRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/preferences":{"put":{"tags":["Customers"],"operationId":"setPreferences","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPreferencesRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/frame-contract":{"put":{"tags":["Customers"],"operationId":"setFrameContract","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetFrameContractRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Customers"],"operationId":"removeFrameContract","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/categories/{id}":{"put":{"tags":["Product Categories"],"operationId":"updateCategory","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductCategoryRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Product Categories"],"operationId":"deleteCategory","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}":{"get":{"tags":["Articles"],"operationId":"getArticle","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Articles"],"operationId":"updateArticle","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateArticleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units/{suId}/price":{"put":{"tags":["Articles"],"operationId":"updateSalesUnitPrice","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"suId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSalesUnitPriceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users":{"get":{"tags":["User Management"],"summary":"List all users","description":"Get a list of all users in the system.","operationId":"listUsers","responses":{"200":{"description":"Users retrieved successfully","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDTO"}}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDTO"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["User Management"],"summary":"Create user (ADMIN only)","description":"Create a new user account with specified roles.","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"400":{"description":"Validation error or invalid password","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Username or email already exists","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"201":{"description":"User created successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/unlock":{"post":{"tags":["User Management"],"summary":"Unlock user (ADMIN only)","description":"Unlock a user account (allows login).","operationId":"unlockUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"User unlocked successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Invalid status transition","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/roles":{"post":{"tags":["User Management"],"summary":"Assign role (ADMIN only)","description":"Assign a role to a user.","operationId":"assignRole","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleRequest"}}},"required":true},"responses":{"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User or role not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"Role assigned successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/lock":{"post":{"tags":["User Management"],"summary":"Lock user (ADMIN only)","description":"Lock a user account (prevents login).","operationId":"lockUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"User locked successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Invalid status transition","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers":{"get":{"tags":["Suppliers"],"operationId":"listSuppliers","parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SupplierResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Suppliers"],"operationId":"createSupplier","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/rating":{"post":{"tags":["Suppliers"],"operationId":"rateSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/deactivate":{"post":{"tags":["Suppliers"],"operationId":"deactivate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/certificates":{"post":{"tags":["Suppliers"],"operationId":"addCertificate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddCertificateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Suppliers"],"operationId":"removeCertificate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveCertificateRequest"}}},"required":true},"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/activate":{"post":{"tags":["Suppliers"],"operationId":"activate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes":{"post":{"tags":["Recipes"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecipeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/ingredients":{"post":{"tags":["Recipes"],"operationId":"addIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddRecipeIngredientRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations":{"get":{"tags":["Storage Locations"],"operationId":"listStorageLocations","parameters":[{"name":"storageType","in":"query","required":false,"schema":{"type":"string"}},{"name":"active","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Storage Locations"],"operationId":"createStorageLocation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStorageLocationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers":{"get":{"tags":["Customers"],"operationId":"listCustomers","parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["B2C","B2B"]}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CustomerResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Customers"],"operationId":"createCustomer","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomerRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/delivery-addresses":{"post":{"tags":["Customers"],"operationId":"addDeliveryAddress","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddDeliveryAddressRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/deactivate":{"post":{"tags":["Customers"],"operationId":"deactivate_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/activate":{"post":{"tags":["Customers"],"operationId":"activate_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/categories":{"get":{"tags":["Product Categories"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Product Categories"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductCategoryRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/auth/refresh":{"post":{"tags":["Authentication"],"summary":"Refresh access token","description":"Refresh an expired access token using a valid refresh token.","operationId":"refresh","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenRequest"}}},"required":true},"responses":{"200":{"description":"Token refresh successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Invalid or expired refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}}}},"/api/auth/logout":{"post":{"tags":["Authentication"],"summary":"User logout","description":"Invalidate current JWT token.","operationId":"logout","responses":{"401":{"description":"Invalid or missing authentication token"},"204":{"description":"Logout successful"}}}},"/api/auth/login":{"post":{"tags":["Authentication"],"summary":"User login","description":"Authenticate user with username and password. Returns JWT tokens.","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"429":{"description":"Too many login attempts","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Invalid credentials, user locked, or user inactive","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"200":{"description":"Login successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}}}},"/api/articles":{"get":{"tags":["Articles"],"operationId":"listArticles","parameters":[{"name":"categoryId","in":"query","required":false,"schema":{"type":"string"}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ArticleResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Articles"],"operationId":"createArticle","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateArticleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/suppliers":{"post":{"tags":["Articles"],"operationId":"assignSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units":{"post":{"tags":["Articles"],"operationId":"addSalesUnit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddSalesUnitRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/deactivate":{"post":{"tags":["Articles"],"operationId":"deactivate_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/activate":{"post":{"tags":["Articles"],"operationId":"activate_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}/deactivate":{"patch":{"tags":["Storage Locations"],"operationId":"deactivateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}/activate":{"patch":{"tags":["Storage Locations"],"operationId":"activateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/roles":{"get":{"tags":["Role Management"],"summary":"List all roles (ADMIN only)","description":"Get a list of all available roles in the system. Requires USER_MANAGEMENT permission.","operationId":"listRoles","responses":{"403":{"description":"Missing USER_MANAGEMENT permission","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}}}}},"200":{"description":"Roles retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/roles/{roleName}":{"delete":{"tags":["User Management"],"summary":"Remove role (ADMIN only)","description":"Remove a role from a user.","operationId":"removeRole","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}},{"name":"roleName","in":"path","description":"Role name","required":true,"schema":{"type":"string","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}}],"responses":{"403":{"description":"Missing permission"},"404":{"description":"User or role not found"},"204":{"description":"Role removed successfully"}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/ingredients/{ingredientId}":{"delete":{"tags":["Recipes"],"operationId":"removeIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"ingredientId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/delivery-addresses/{label}":{"delete":{"tags":["Customers"],"operationId":"removeDeliveryAddress","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"label","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/suppliers/{supplierId}":{"delete":{"tags":["Articles"],"operationId":"removeSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"supplierId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units/{suId}":{"delete":{"tags":["Articles"],"operationId":"removeSalesUnit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"suId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}}},"components":{"schemas":{"UpdateUserRequest":{"type":"object","properties":{"email":{"type":"string","description":"New email address","example":"newemail@example.com"},"branchId":{"type":"string","description":"New branch ID","example":"BRANCH-002"}},"description":"Request to update user details"},"RoleDTO":{"required":["description","id","name","permissions"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]},"permissions":{"uniqueItems":true,"type":"array","items":{"type":"string","enum":["RECIPE_READ","RECIPE_WRITE","RECIPE_DELETE","BATCH_READ","BATCH_WRITE","BATCH_COMPLETE","BATCH_DELETE","PRODUCTION_ORDER_READ","PRODUCTION_ORDER_WRITE","PRODUCTION_ORDER_DELETE","HACCP_READ","HACCP_WRITE","TEMPERATURE_LOG_READ","TEMPERATURE_LOG_WRITE","CLEANING_RECORD_READ","CLEANING_RECORD_WRITE","GOODS_INSPECTION_READ","GOODS_INSPECTION_WRITE","STOCK_READ","STOCK_WRITE","STOCK_MOVEMENT_READ","STOCK_MOVEMENT_WRITE","INVENTORY_COUNT_READ","INVENTORY_COUNT_WRITE","PURCHASE_ORDER_READ","PURCHASE_ORDER_WRITE","PURCHASE_ORDER_DELETE","GOODS_RECEIPT_READ","GOODS_RECEIPT_WRITE","SUPPLIER_READ","SUPPLIER_WRITE","SUPPLIER_DELETE","ORDER_READ","ORDER_WRITE","ORDER_DELETE","INVOICE_READ","INVOICE_WRITE","INVOICE_DELETE","CUSTOMER_READ","CUSTOMER_WRITE","CUSTOMER_DELETE","LABEL_READ","LABEL_WRITE","LABEL_PRINT","MASTERDATA_READ","MASTERDATA_WRITE","BRANCH_READ","BRANCH_WRITE","BRANCH_DELETE","USER_READ","USER_WRITE","USER_DELETE","USER_LOCK","USER_UNLOCK","ROLE_READ","ROLE_WRITE","ROLE_ASSIGN","ROLE_REMOVE","REPORT_READ","REPORT_GENERATE","NOTIFICATION_READ","NOTIFICATION_SEND","AUDIT_LOG_READ","SYSTEM_SETTINGS_READ","SYSTEM_SETTINGS_WRITE"]}},"description":{"type":"string"}}},"UserDTO":{"required":["createdAt","email","id","roles","status","username"],"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"},"roles":{"uniqueItems":true,"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}},"branchId":{"type":"string"},"status":{"type":"string","enum":["ACTIVE","INACTIVE","LOCKED"]},"createdAt":{"type":"string","format":"date-time"},"lastLogin":{"type":"string","format":"date-time"}}},"ChangePasswordRequest":{"required":["currentPassword","newPassword"],"type":"object","properties":{"currentPassword":{"type":"string","description":"Current password","example":"OldPass123"},"newPassword":{"maxLength":2147483647,"minLength":8,"type":"string","description":"New password (min 8 characters)","example":"NewSecurePass456"}},"description":"Request to change user password"},"UpdateSupplierRequest":{"type":"object","properties":{"name":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"AddressResponse":{"required":["city","country","houseNumber","postalCode","street"],"type":"object","properties":{"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"}},"nullable":true},"ContactInfoResponse":{"required":["contactPerson","email","phone"],"type":"object","properties":{"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"}}},"PaymentTermsResponse":{"required":["paymentDescription","paymentDueDays"],"type":"object","properties":{"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}},"nullable":true},"QualityCertificateResponse":{"required":["certificateType","issuer","validFrom","validUntil"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"}}},"SupplierRatingResponse":{"required":["deliveryScore","priceScore","qualityScore"],"type":"object","properties":{"qualityScore":{"type":"integer","format":"int32"},"deliveryScore":{"type":"integer","format":"int32"},"priceScore":{"type":"integer","format":"int32"}},"nullable":true},"SupplierResponse":{"required":["certificates","contactInfo","createdAt","id","name","status","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"address":{"$ref":"#/components/schemas/AddressResponse"},"contactInfo":{"$ref":"#/components/schemas/ContactInfoResponse"},"paymentTerms":{"$ref":"#/components/schemas/PaymentTermsResponse"},"certificates":{"type":"array","items":{"$ref":"#/components/schemas/QualityCertificateResponse"}},"rating":{"$ref":"#/components/schemas/SupplierRatingResponse"},"status":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"UpdateStorageLocationRequest":{"type":"object","properties":{"name":{"type":"string"},"minTemperature":{"type":"string"},"maxTemperature":{"type":"string"}}},"StorageLocationResponse":{"required":["active","id","name","storageType"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"storageType":{"type":"string"},"temperatureRange":{"$ref":"#/components/schemas/TemperatureRangeResponse"},"active":{"type":"boolean"}}},"TemperatureRangeResponse":{"required":["maxTemperature","minTemperature"],"type":"object","properties":{"minTemperature":{"type":"number"},"maxTemperature":{"type":"number"}},"nullable":true},"UpdateCustomerRequest":{"type":"object","properties":{"name":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"ContractLineItemResponse":{"required":["agreedPrice","agreedQuantity","articleId","unit"],"type":"object","properties":{"articleId":{"type":"string"},"agreedPrice":{"type":"number"},"agreedQuantity":{"type":"number"},"unit":{"type":"string"}}},"CustomerResponse":{"required":["billingAddress","contactInfo","createdAt","deliveryAddresses","id","name","preferences","status","type","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"billingAddress":{"$ref":"#/components/schemas/AddressResponse"},"contactInfo":{"$ref":"#/components/schemas/ContactInfoResponse"},"paymentTerms":{"$ref":"#/components/schemas/PaymentTermsResponse"},"deliveryAddresses":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryAddressResponse"}},"frameContract":{"$ref":"#/components/schemas/FrameContractResponse"},"preferences":{"type":"array","items":{"type":"string"}},"status":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"DeliveryAddressResponse":{"required":["address","contactPerson","deliveryNotes","label"],"type":"object","properties":{"label":{"type":"string"},"address":{"$ref":"#/components/schemas/AddressResponse"},"contactPerson":{"type":"string"},"deliveryNotes":{"type":"string"}}},"FrameContractResponse":{"required":["deliveryRhythm","id","lineItems","validFrom","validUntil"],"type":"object","properties":{"id":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"},"deliveryRhythm":{"type":"string"},"lineItems":{"type":"array","items":{"$ref":"#/components/schemas/ContractLineItemResponse"}}},"nullable":true},"SetPreferencesRequest":{"required":["preferences"],"type":"object","properties":{"preferences":{"uniqueItems":true,"type":"array","items":{"type":"string","enum":["BIO","REGIONAL","TIERWOHL","HALAL","KOSHER","GLUTENFREI","LAKTOSEFREI"]}}}},"LineItem":{"required":["agreedPrice","articleId"],"type":"object","properties":{"articleId":{"type":"string"},"agreedPrice":{"type":"number"},"agreedQuantity":{"type":"number"},"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]}}},"SetFrameContractRequest":{"required":["lineItems","rhythm"],"type":"object","properties":{"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"},"rhythm":{"type":"string","enum":["DAILY","WEEKLY","BIWEEKLY","MONTHLY","ON_DEMAND"]},"lineItems":{"type":"array","items":{"$ref":"#/components/schemas/LineItem"}}}},"UpdateProductCategoryRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ProductCategoryResponse":{"required":["description","id","name"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"}}},"UpdateArticleRequest":{"type":"object","properties":{"name":{"type":"string"},"categoryId":{"type":"string"}}},"ArticleResponse":{"required":["articleNumber","categoryId","createdAt","id","name","salesUnits","status","supplierIds","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"articleNumber":{"type":"string"},"categoryId":{"type":"string"},"salesUnits":{"type":"array","items":{"$ref":"#/components/schemas/SalesUnitResponse"}},"status":{"type":"string"},"supplierIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"SalesUnitResponse":{"required":["id","price","priceModel","unit"],"type":"object","properties":{"id":{"type":"string"},"unit":{"type":"string"},"priceModel":{"type":"string"},"price":{"type":"number"}}},"UpdateSalesUnitPriceRequest":{"required":["price"],"type":"object","properties":{"price":{"type":"number"}}},"CreateUserRequest":{"required":["email","password","roleNames","username"],"type":"object","properties":{"username":{"maxLength":50,"minLength":3,"type":"string","description":"Username (unique)","example":"john.doe"},"email":{"type":"string","description":"Email address (unique)","example":"john.doe@example.com"},"password":{"maxLength":2147483647,"minLength":8,"type":"string","description":"Password (min 8 characters)","example":"SecurePass123"},"roleNames":{"uniqueItems":true,"type":"array","description":"Role names to assign","example":["USER","MANAGER"],"items":{"type":"string","description":"Role names to assign","example":"[\"USER\",\"MANAGER\"]","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}},"branchId":{"type":"string","description":"Branch ID (optional)","example":"BRANCH-001"}},"description":"Request to create a new user"},"AssignRoleRequest":{"required":["roleName"],"type":"object","properties":{"roleName":{"type":"string","description":"Role name to assign","example":"MANAGER","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}},"description":"Request to assign a role to a user"},"CreateSupplierRequest":{"required":["name","phone"],"type":"object","properties":{"name":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"RateSupplierRequest":{"type":"object","properties":{"qualityScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"},"deliveryScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"},"priceScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"}}},"AddCertificateRequest":{"required":["certificateType"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"}}},"CreateRecipeRequest":{"required":["name","outputQuantity","outputUom","type"],"type":"object","properties":{"name":{"type":"string"},"version":{"type":"integer","format":"int32"},"type":{"type":"string","enum":["RAW_MATERIAL","INTERMEDIATE","FINISHED_PRODUCT"]},"description":{"type":"string"},"yieldPercentage":{"type":"integer","format":"int32"},"shelfLifeDays":{"type":"integer","format":"int32"},"outputQuantity":{"type":"string"},"outputUom":{"type":"string"}}},"IngredientResponse":{"required":["articleId","id","position","quantity","substitutable","uom"],"type":"object","properties":{"id":{"type":"string"},"position":{"type":"integer","format":"int32"},"articleId":{"type":"string"},"quantity":{"type":"string"},"uom":{"type":"string"},"subRecipeId":{"type":"string","nullable":true},"substitutable":{"type":"boolean"}}},"RecipeResponse":{"required":["createdAt","description","id","ingredients","name","outputQuantity","outputUom","status","type","updatedAt","version","yieldPercentage"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"version":{"type":"integer","format":"int32"},"type":{"type":"string"},"description":{"type":"string"},"yieldPercentage":{"type":"integer","format":"int32"},"shelfLifeDays":{"type":"integer","format":"int32","nullable":true},"outputQuantity":{"type":"string"},"outputUom":{"type":"string"},"status":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"AddRecipeIngredientRequest":{"required":["articleId","quantity","uom"],"type":"object","properties":{"position":{"minimum":1,"type":"integer","format":"int32"},"articleId":{"type":"string"},"quantity":{"type":"string"},"uom":{"type":"string"},"subRecipeId":{"type":"string"},"substitutable":{"type":"boolean"}}},"CreateStorageLocationRequest":{"required":["name","storageType"],"type":"object","properties":{"name":{"type":"string"},"storageType":{"type":"string"},"minTemperature":{"type":"string"},"maxTemperature":{"type":"string"}}},"CreateCustomerRequest":{"required":["city","country","name","phone","postalCode","street","type"],"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["B2C","B2B"]},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"AddDeliveryAddressRequest":{"required":["city","country","postalCode","street"],"type":"object","properties":{"label":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"contactPerson":{"type":"string"},"deliveryNotes":{"type":"string"}}},"CreateProductCategoryRequest":{"required":["name"],"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"RefreshTokenRequest":{"required":["refreshToken"],"type":"object","properties":{"refreshToken":{"type":"string","description":"Refresh token"}},"description":"Refresh token request"},"LoginResponse":{"required":["accessToken","expiresAt","expiresIn","refreshToken","tokenType"],"type":"object","properties":{"accessToken":{"type":"string","description":"JWT access token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"tokenType":{"type":"string","description":"Token type","example":"Bearer"},"expiresIn":{"type":"integer","description":"Token expiration time in seconds","format":"int64","example":3600},"expiresAt":{"type":"string","description":"Token expiration timestamp","format":"date-time"},"refreshToken":{"type":"string","description":"Refresh token for obtaining new access token"}},"description":"Login response with JWT tokens"},"LoginRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string","description":"Username","example":"admin"},"password":{"type":"string","description":"Password","example":"admin123"}},"description":"Login request with username and password"},"CreateArticleRequest":{"required":["articleNumber","categoryId","name","price","priceModel","unit"],"type":"object","properties":{"name":{"type":"string"},"articleNumber":{"type":"string"},"categoryId":{"type":"string"},"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]},"priceModel":{"type":"string","enum":["FIXED","WEIGHT_BASED"]},"price":{"type":"number"}}},"AssignSupplierRequest":{"required":["supplierId"],"type":"object","properties":{"supplierId":{"type":"string"}}},"AddSalesUnitRequest":{"required":["price","priceModel","unit"],"type":"object","properties":{"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]},"priceModel":{"type":"string","enum":["FIXED","WEIGHT_BASED"]},"price":{"type":"number"}}},"RemoveCertificateRequest":{"required":["certificateType"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"}}}},"securitySchemes":{"Bearer Authentication":{"type":"http","description":"JWT authentication token obtained from POST /api/auth/login.\n\nFormat: Bearer \n\nExample:\nBearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.0.1","info":{"title":"Effigenix Fleischerei ERP API","description":"RESTful API for Effigenix Fleischerei ERP System.\n\n## Authentication\n\nAll endpoints (except /api/auth/login and /api/auth/refresh) require JWT authentication.\n\n1. Login via POST /api/auth/login with username and password\n2. Copy the returned access token\n3. Click \"Authorize\" button (top right)\n4. Enter: Bearer \n5. Click \"Authorize\"\n\n## User Management\n\n- **Authentication**: Login, logout, refresh token\n- **User Management**: Create, update, list users (ADMIN only)\n- **Role Management**: Assign roles, lock/unlock users (ADMIN only)\n- **Password Management**: Change password (requires current password)\n\n## Error Handling\n\nAll errors return a consistent error response format:\n\n```json\n{\n \"code\": \"USER_NOT_FOUND\",\n \"message\": \"User with ID 'user-123' not found\",\n \"status\": 404,\n \"timestamp\": \"2026-02-17T12:00:00\",\n \"path\": \"/api/users/user-123\",\n \"validationErrors\": null\n}\n```\n\n## Architecture\n\nBuilt with:\n- Domain-Driven Design (DDD)\n- Clean Architecture (Hexagonal Architecture)\n- Spring Boot 3.2\n- Java 21\n- PostgreSQL\n","contact":{"name":"Effigenix Development Team","url":"https://effigenix.com","email":"dev@effigenix.com"},"license":{"name":"Proprietary","url":"https://effigenix.com/license"},"version":"0.1.0"},"servers":[{"url":"http://localhost:8080","description":"Local Development Server"},{"url":"https://api.effigenix.com","description":"Production Server"}],"tags":[{"name":"Storage Locations","description":"Storage location management endpoints"},{"name":"Product Categories","description":"Product category management endpoints"},{"name":"User Management","description":"User management endpoints (requires authentication)"},{"name":"Articles","description":"Article management endpoints"},{"name":"Recipes","description":"Recipe management endpoints"},{"name":"Role Management","description":"Role management endpoints (ADMIN only)"},{"name":"Customers","description":"Customer management endpoints"},{"name":"Suppliers","description":"Supplier management endpoints"},{"name":"Authentication","description":"Authentication and session management endpoints"}],"paths":{"/api/users/{id}":{"get":{"tags":["User Management"],"summary":"Get user by ID","description":"Retrieve a single user by their ID.","operationId":"getUserById","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["User Management"],"summary":"Update user","description":"Update user details (email, branchId).","operationId":"updateUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"409":{"description":"Email already exists","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"User updated successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/password":{"put":{"tags":["User Management"],"summary":"Change password","description":"Change user password. Requires current password for verification.","operationId":"changePassword","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordRequest"}}},"required":true},"responses":{"400":{"description":"Invalid password"},"401":{"description":"Invalid current password"},"404":{"description":"User not found"},"204":{"description":"Password changed successfully"}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}":{"get":{"tags":["Suppliers"],"operationId":"getSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Suppliers"],"operationId":"updateSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}":{"put":{"tags":["Storage Locations"],"operationId":"updateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateStorageLocationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}":{"get":{"tags":["Customers"],"operationId":"getCustomer","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Customers"],"operationId":"updateCustomer","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCustomerRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/preferences":{"put":{"tags":["Customers"],"operationId":"setPreferences","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPreferencesRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/frame-contract":{"put":{"tags":["Customers"],"operationId":"setFrameContract","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetFrameContractRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Customers"],"operationId":"removeFrameContract","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/categories/{id}":{"put":{"tags":["Product Categories"],"operationId":"updateCategory","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProductCategoryRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Product Categories"],"operationId":"deleteCategory","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}":{"get":{"tags":["Articles"],"operationId":"getArticle","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"put":{"tags":["Articles"],"operationId":"updateArticle","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateArticleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units/{suId}/price":{"put":{"tags":["Articles"],"operationId":"updateSalesUnitPrice","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"suId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSalesUnitPriceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users":{"get":{"tags":["User Management"],"summary":"List all users","description":"Get a list of all users in the system.","operationId":"listUsers","responses":{"200":{"description":"Users retrieved successfully","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDTO"}}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserDTO"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["User Management"],"summary":"Create user (ADMIN only)","description":"Create a new user account with specified roles.","operationId":"createUser","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"400":{"description":"Validation error or invalid password","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Username or email already exists","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"201":{"description":"User created successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/unlock":{"post":{"tags":["User Management"],"summary":"Unlock user (ADMIN only)","description":"Unlock a user account (allows login).","operationId":"unlockUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"User unlocked successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Invalid status transition","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/roles":{"post":{"tags":["User Management"],"summary":"Assign role (ADMIN only)","description":"Assign a role to a user.","operationId":"assignRole","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignRoleRequest"}}},"required":true},"responses":{"200":{"description":"Role assigned successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User or role not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/lock":{"post":{"tags":["User Management"],"summary":"Lock user (ADMIN only)","description":"Lock a user account (prevents login).","operationId":"lockUser","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User locked successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"404":{"description":"User not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Invalid status transition","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers":{"get":{"tags":["Suppliers"],"operationId":"listSuppliers","parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SupplierResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Suppliers"],"operationId":"createSupplier","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/rating":{"post":{"tags":["Suppliers"],"operationId":"rateSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/deactivate":{"post":{"tags":["Suppliers"],"operationId":"deactivate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/certificates":{"post":{"tags":["Suppliers"],"operationId":"addCertificate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddCertificateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]},"delete":{"tags":["Suppliers"],"operationId":"removeCertificate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveCertificateRequest"}}},"required":true},"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/suppliers/{id}/activate":{"post":{"tags":["Suppliers"],"operationId":"activate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SupplierResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes":{"post":{"tags":["Recipes"],"operationId":"createRecipe","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecipeRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/steps":{"post":{"tags":["Recipes"],"operationId":"addProductionStep","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddProductionStepRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/ingredients":{"post":{"tags":["Recipes"],"operationId":"addIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddRecipeIngredientRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations":{"get":{"tags":["Storage Locations"],"operationId":"listStorageLocations","parameters":[{"name":"storageType","in":"query","required":false,"schema":{"type":"string"}},{"name":"active","in":"query","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Storage Locations"],"operationId":"createStorageLocation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStorageLocationRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers":{"get":{"tags":["Customers"],"operationId":"listCustomers","parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["B2C","B2B"]}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CustomerResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Customers"],"operationId":"createCustomer","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomerRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/delivery-addresses":{"post":{"tags":["Customers"],"operationId":"addDeliveryAddress","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddDeliveryAddressRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/deactivate":{"post":{"tags":["Customers"],"operationId":"deactivate_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/activate":{"post":{"tags":["Customers"],"operationId":"activate_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CustomerResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/categories":{"get":{"tags":["Product Categories"],"operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Product Categories"],"operationId":"createCategory","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProductCategoryRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProductCategoryResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/auth/refresh":{"post":{"tags":["Authentication"],"summary":"Refresh access token","description":"Refresh an expired access token using a valid refresh token.","operationId":"refresh","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenRequest"}}},"required":true},"responses":{"401":{"description":"Invalid or expired refresh token","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"200":{"description":"Token refresh successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}}}},"/api/auth/logout":{"post":{"tags":["Authentication"],"summary":"User logout","description":"Invalidate current JWT token.","operationId":"logout","responses":{"401":{"description":"Invalid or missing authentication token"},"204":{"description":"Logout successful"}}}},"/api/auth/login":{"post":{"tags":["Authentication"],"summary":"User login","description":"Authenticate user with username and password. Returns JWT tokens.","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Login successful","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"429":{"description":"Too many login attempts","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Invalid credentials, user locked, or user inactive","content":{"*/*":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}}}},"/api/articles":{"get":{"tags":["Articles"],"operationId":"listArticles","parameters":[{"name":"categoryId","in":"query","required":false,"schema":{"type":"string"}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["ACTIVE","INACTIVE"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ArticleResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"post":{"tags":["Articles"],"operationId":"createArticle","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateArticleRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/suppliers":{"post":{"tags":["Articles"],"operationId":"assignSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignSupplierRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units":{"post":{"tags":["Articles"],"operationId":"addSalesUnit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddSalesUnitRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/deactivate":{"post":{"tags":["Articles"],"operationId":"deactivate_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/activate":{"post":{"tags":["Articles"],"operationId":"activate_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ArticleResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}/deactivate":{"patch":{"tags":["Storage Locations"],"operationId":"deactivateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/storage-locations/{id}/activate":{"patch":{"tags":["Storage Locations"],"operationId":"activateStorageLocation","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StorageLocationResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/roles":{"get":{"tags":["Role Management"],"summary":"List all roles (ADMIN only)","description":"Get a list of all available roles in the system. Requires USER_MANAGEMENT permission.","operationId":"listRoles","responses":{"200":{"description":"Roles retrieved successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RoleDTO"}}}},"403":{"description":"Missing USER_MANAGEMENT permission","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}}}}},"401":{"description":"Authentication required","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/users/{id}/roles/{roleName}":{"delete":{"tags":["User Management"],"summary":"Remove role (ADMIN only)","description":"Remove a role from a user.","operationId":"removeRole","parameters":[{"name":"id","in":"path","description":"User ID","required":true,"schema":{"type":"string"}},{"name":"roleName","in":"path","description":"Role name","required":true,"schema":{"type":"string","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}}],"responses":{"403":{"description":"Missing permission"},"404":{"description":"User or role not found"},"204":{"description":"Role removed successfully"}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/steps/{stepNumber}":{"delete":{"tags":["Recipes"],"operationId":"removeProductionStep","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"stepNumber","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/ingredients/{ingredientId}":{"delete":{"tags":["Recipes"],"operationId":"removeIngredient","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"ingredientId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/customers/{id}/delivery-addresses/{label}":{"delete":{"tags":["Customers"],"operationId":"removeDeliveryAddress","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"label","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/suppliers/{supplierId}":{"delete":{"tags":["Articles"],"operationId":"removeSupplier","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"supplierId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/articles/{id}/sales-units/{suId}":{"delete":{"tags":["Articles"],"operationId":"removeSalesUnit","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"suId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}}},"components":{"schemas":{"UpdateUserRequest":{"type":"object","properties":{"email":{"type":"string","description":"New email address","example":"newemail@example.com"},"branchId":{"type":"string","description":"New branch ID","example":"BRANCH-002"}},"description":"Request to update user details"},"RoleDTO":{"required":["description","id","name","permissions"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]},"permissions":{"uniqueItems":true,"type":"array","items":{"type":"string","enum":["RECIPE_READ","RECIPE_WRITE","RECIPE_DELETE","BATCH_READ","BATCH_WRITE","BATCH_COMPLETE","BATCH_DELETE","PRODUCTION_ORDER_READ","PRODUCTION_ORDER_WRITE","PRODUCTION_ORDER_DELETE","HACCP_READ","HACCP_WRITE","TEMPERATURE_LOG_READ","TEMPERATURE_LOG_WRITE","CLEANING_RECORD_READ","CLEANING_RECORD_WRITE","GOODS_INSPECTION_READ","GOODS_INSPECTION_WRITE","STOCK_READ","STOCK_WRITE","STOCK_MOVEMENT_READ","STOCK_MOVEMENT_WRITE","INVENTORY_COUNT_READ","INVENTORY_COUNT_WRITE","PURCHASE_ORDER_READ","PURCHASE_ORDER_WRITE","PURCHASE_ORDER_DELETE","GOODS_RECEIPT_READ","GOODS_RECEIPT_WRITE","SUPPLIER_READ","SUPPLIER_WRITE","SUPPLIER_DELETE","ORDER_READ","ORDER_WRITE","ORDER_DELETE","INVOICE_READ","INVOICE_WRITE","INVOICE_DELETE","CUSTOMER_READ","CUSTOMER_WRITE","CUSTOMER_DELETE","LABEL_READ","LABEL_WRITE","LABEL_PRINT","MASTERDATA_READ","MASTERDATA_WRITE","BRANCH_READ","BRANCH_WRITE","BRANCH_DELETE","USER_READ","USER_WRITE","USER_DELETE","USER_LOCK","USER_UNLOCK","ROLE_READ","ROLE_WRITE","ROLE_ASSIGN","ROLE_REMOVE","REPORT_READ","REPORT_GENERATE","NOTIFICATION_READ","NOTIFICATION_SEND","AUDIT_LOG_READ","SYSTEM_SETTINGS_READ","SYSTEM_SETTINGS_WRITE"]}},"description":{"type":"string"}}},"UserDTO":{"required":["createdAt","email","id","roles","status","username"],"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"},"roles":{"uniqueItems":true,"type":"array","items":{"$ref":"#/components/schemas/RoleDTO"}},"branchId":{"type":"string"},"status":{"type":"string","enum":["ACTIVE","INACTIVE","LOCKED"]},"createdAt":{"type":"string","format":"date-time"},"lastLogin":{"type":"string","format":"date-time"}}},"ChangePasswordRequest":{"required":["currentPassword","newPassword"],"type":"object","properties":{"currentPassword":{"type":"string","description":"Current password","example":"OldPass123"},"newPassword":{"maxLength":2147483647,"minLength":8,"type":"string","description":"New password (min 8 characters)","example":"NewSecurePass456"}},"description":"Request to change user password"},"UpdateSupplierRequest":{"type":"object","properties":{"name":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"AddressResponse":{"required":["city","country","houseNumber","postalCode","street"],"type":"object","properties":{"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"}},"nullable":true},"ContactInfoResponse":{"required":["contactPerson","email","phone"],"type":"object","properties":{"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"}}},"PaymentTermsResponse":{"required":["paymentDescription","paymentDueDays"],"type":"object","properties":{"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}},"nullable":true},"QualityCertificateResponse":{"required":["certificateType","issuer","validFrom","validUntil"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"}}},"SupplierRatingResponse":{"required":["deliveryScore","priceScore","qualityScore"],"type":"object","properties":{"qualityScore":{"type":"integer","format":"int32"},"deliveryScore":{"type":"integer","format":"int32"},"priceScore":{"type":"integer","format":"int32"}},"nullable":true},"SupplierResponse":{"required":["certificates","contactInfo","createdAt","id","name","status","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"address":{"$ref":"#/components/schemas/AddressResponse"},"contactInfo":{"$ref":"#/components/schemas/ContactInfoResponse"},"paymentTerms":{"$ref":"#/components/schemas/PaymentTermsResponse"},"certificates":{"type":"array","items":{"$ref":"#/components/schemas/QualityCertificateResponse"}},"rating":{"$ref":"#/components/schemas/SupplierRatingResponse"},"status":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"UpdateStorageLocationRequest":{"type":"object","properties":{"name":{"type":"string"},"minTemperature":{"type":"string"},"maxTemperature":{"type":"string"}}},"StorageLocationResponse":{"required":["active","id","name","storageType"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"storageType":{"type":"string"},"temperatureRange":{"$ref":"#/components/schemas/TemperatureRangeResponse"},"active":{"type":"boolean"}}},"TemperatureRangeResponse":{"required":["maxTemperature","minTemperature"],"type":"object","properties":{"minTemperature":{"type":"number"},"maxTemperature":{"type":"number"}},"nullable":true},"UpdateCustomerRequest":{"type":"object","properties":{"name":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"ContractLineItemResponse":{"required":["agreedPrice","agreedQuantity","articleId","unit"],"type":"object","properties":{"articleId":{"type":"string"},"agreedPrice":{"type":"number"},"agreedQuantity":{"type":"number"},"unit":{"type":"string"}}},"CustomerResponse":{"required":["billingAddress","contactInfo","createdAt","deliveryAddresses","id","name","preferences","status","type","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"billingAddress":{"$ref":"#/components/schemas/AddressResponse"},"contactInfo":{"$ref":"#/components/schemas/ContactInfoResponse"},"paymentTerms":{"$ref":"#/components/schemas/PaymentTermsResponse"},"deliveryAddresses":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryAddressResponse"}},"frameContract":{"$ref":"#/components/schemas/FrameContractResponse"},"preferences":{"type":"array","items":{"type":"string"}},"status":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"DeliveryAddressResponse":{"required":["address","contactPerson","deliveryNotes","label"],"type":"object","properties":{"label":{"type":"string"},"address":{"$ref":"#/components/schemas/AddressResponse"},"contactPerson":{"type":"string"},"deliveryNotes":{"type":"string"}}},"FrameContractResponse":{"required":["deliveryRhythm","id","lineItems","validFrom","validUntil"],"type":"object","properties":{"id":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"},"deliveryRhythm":{"type":"string"},"lineItems":{"type":"array","items":{"$ref":"#/components/schemas/ContractLineItemResponse"}}},"nullable":true},"SetPreferencesRequest":{"required":["preferences"],"type":"object","properties":{"preferences":{"uniqueItems":true,"type":"array","items":{"type":"string","enum":["BIO","REGIONAL","TIERWOHL","HALAL","KOSHER","GLUTENFREI","LAKTOSEFREI"]}}}},"LineItem":{"required":["agreedPrice","articleId"],"type":"object","properties":{"articleId":{"type":"string"},"agreedPrice":{"type":"number"},"agreedQuantity":{"type":"number"},"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]}}},"SetFrameContractRequest":{"required":["lineItems","rhythm"],"type":"object","properties":{"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"},"rhythm":{"type":"string","enum":["DAILY","WEEKLY","BIWEEKLY","MONTHLY","ON_DEMAND"]},"lineItems":{"type":"array","items":{"$ref":"#/components/schemas/LineItem"}}}},"UpdateProductCategoryRequest":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ProductCategoryResponse":{"required":["description","id","name"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"}}},"UpdateArticleRequest":{"type":"object","properties":{"name":{"type":"string"},"categoryId":{"type":"string"}}},"ArticleResponse":{"required":["articleNumber","categoryId","createdAt","id","name","salesUnits","status","supplierIds","updatedAt"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"articleNumber":{"type":"string"},"categoryId":{"type":"string"},"salesUnits":{"type":"array","items":{"$ref":"#/components/schemas/SalesUnitResponse"}},"status":{"type":"string"},"supplierIds":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"SalesUnitResponse":{"required":["id","price","priceModel","unit"],"type":"object","properties":{"id":{"type":"string"},"unit":{"type":"string"},"priceModel":{"type":"string"},"price":{"type":"number"}}},"UpdateSalesUnitPriceRequest":{"required":["price"],"type":"object","properties":{"price":{"type":"number"}}},"CreateUserRequest":{"required":["email","password","roleNames","username"],"type":"object","properties":{"username":{"maxLength":50,"minLength":3,"type":"string","description":"Username (unique)","example":"john.doe"},"email":{"type":"string","description":"Email address (unique)","example":"john.doe@example.com"},"password":{"maxLength":2147483647,"minLength":8,"type":"string","description":"Password (min 8 characters)","example":"SecurePass123"},"roleNames":{"uniqueItems":true,"type":"array","description":"Role names to assign","example":["USER","MANAGER"],"items":{"type":"string","description":"Role names to assign","example":"[\"USER\",\"MANAGER\"]","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}},"branchId":{"type":"string","description":"Branch ID (optional)","example":"BRANCH-001"}},"description":"Request to create a new user"},"AssignRoleRequest":{"required":["roleName"],"type":"object","properties":{"roleName":{"type":"string","description":"Role name to assign","example":"MANAGER","enum":["ADMIN","PRODUCTION_MANAGER","PRODUCTION_WORKER","QUALITY_MANAGER","QUALITY_INSPECTOR","PROCUREMENT_MANAGER","WAREHOUSE_WORKER","SALES_MANAGER","SALES_STAFF"]}},"description":"Request to assign a role to a user"},"CreateSupplierRequest":{"required":["name","phone"],"type":"object","properties":{"name":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"RateSupplierRequest":{"type":"object","properties":{"qualityScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"},"deliveryScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"},"priceScore":{"maximum":5,"minimum":1,"type":"integer","format":"int32"}}},"AddCertificateRequest":{"required":["certificateType"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"},"validUntil":{"type":"string","format":"date"}}},"CreateRecipeRequest":{"required":["name","outputQuantity","outputUom","type"],"type":"object","properties":{"name":{"type":"string"},"version":{"type":"integer","format":"int32"},"type":{"type":"string","enum":["RAW_MATERIAL","INTERMEDIATE","FINISHED_PRODUCT"]},"description":{"type":"string"},"yieldPercentage":{"type":"integer","format":"int32"},"shelfLifeDays":{"type":"integer","format":"int32"},"outputQuantity":{"type":"string"},"outputUom":{"type":"string"}}},"IngredientResponse":{"required":["articleId","id","position","quantity","substitutable","uom"],"type":"object","properties":{"id":{"type":"string"},"position":{"type":"integer","format":"int32"},"articleId":{"type":"string"},"quantity":{"type":"string"},"uom":{"type":"string"},"subRecipeId":{"type":"string","nullable":true},"substitutable":{"type":"boolean"}}},"ProductionStepResponse":{"required":["description","id","stepNumber"],"type":"object","properties":{"id":{"type":"string"},"stepNumber":{"type":"integer","format":"int32"},"description":{"type":"string"},"durationMinutes":{"type":"integer","format":"int32","nullable":true},"temperatureCelsius":{"type":"integer","format":"int32","nullable":true}}},"RecipeResponse":{"required":["createdAt","description","id","ingredients","name","outputQuantity","outputUom","productionSteps","status","type","updatedAt","version","yieldPercentage"],"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"version":{"type":"integer","format":"int32"},"type":{"type":"string"},"description":{"type":"string"},"yieldPercentage":{"type":"integer","format":"int32"},"shelfLifeDays":{"type":"integer","format":"int32","nullable":true},"outputQuantity":{"type":"string"},"outputUom":{"type":"string"},"status":{"type":"string"},"ingredients":{"type":"array","items":{"$ref":"#/components/schemas/IngredientResponse"}},"productionSteps":{"type":"array","items":{"$ref":"#/components/schemas/ProductionStepResponse"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"AddProductionStepRequest":{"required":["description"],"type":"object","properties":{"stepNumber":{"minimum":1,"type":"integer","format":"int32"},"description":{"type":"string"},"durationMinutes":{"type":"integer","format":"int32"},"temperatureCelsius":{"type":"integer","format":"int32"}}},"AddRecipeIngredientRequest":{"required":["articleId","quantity","uom"],"type":"object","properties":{"position":{"minimum":1,"type":"integer","format":"int32"},"articleId":{"type":"string"},"quantity":{"type":"string"},"uom":{"type":"string"},"subRecipeId":{"type":"string"},"substitutable":{"type":"boolean"}}},"CreateStorageLocationRequest":{"required":["name","storageType"],"type":"object","properties":{"name":{"type":"string"},"storageType":{"type":"string"},"minTemperature":{"type":"string"},"maxTemperature":{"type":"string"}}},"CreateCustomerRequest":{"required":["city","country","name","phone","postalCode","street","type"],"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["B2C","B2B"]},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"contactPerson":{"type":"string"},"paymentDueDays":{"type":"integer","format":"int32"},"paymentDescription":{"type":"string"}}},"AddDeliveryAddressRequest":{"required":["city","country","postalCode","street"],"type":"object","properties":{"label":{"type":"string"},"street":{"type":"string"},"houseNumber":{"type":"string"},"postalCode":{"type":"string"},"city":{"type":"string"},"country":{"type":"string"},"contactPerson":{"type":"string"},"deliveryNotes":{"type":"string"}}},"CreateProductCategoryRequest":{"required":["name"],"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"RefreshTokenRequest":{"required":["refreshToken"],"type":"object","properties":{"refreshToken":{"type":"string","description":"Refresh token"}},"description":"Refresh token request"},"LoginResponse":{"required":["accessToken","expiresAt","expiresIn","refreshToken","tokenType"],"type":"object","properties":{"accessToken":{"type":"string","description":"JWT access token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"tokenType":{"type":"string","description":"Token type","example":"Bearer"},"expiresIn":{"type":"integer","description":"Token expiration time in seconds","format":"int64","example":3600},"expiresAt":{"type":"string","description":"Token expiration timestamp","format":"date-time"},"refreshToken":{"type":"string","description":"Refresh token for obtaining new access token"}},"description":"Login response with JWT tokens"},"LoginRequest":{"required":["password","username"],"type":"object","properties":{"username":{"type":"string","description":"Username","example":"admin"},"password":{"type":"string","description":"Password","example":"admin123"}},"description":"Login request with username and password"},"CreateArticleRequest":{"required":["articleNumber","categoryId","name","price","priceModel","unit"],"type":"object","properties":{"name":{"type":"string"},"articleNumber":{"type":"string"},"categoryId":{"type":"string"},"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]},"priceModel":{"type":"string","enum":["FIXED","WEIGHT_BASED"]},"price":{"type":"number"}}},"AssignSupplierRequest":{"required":["supplierId"],"type":"object","properties":{"supplierId":{"type":"string"}}},"AddSalesUnitRequest":{"required":["price","priceModel","unit"],"type":"object","properties":{"unit":{"type":"string","enum":["PIECE_FIXED","KG","HUNDRED_GRAM","PIECE_VARIABLE"]},"priceModel":{"type":"string","enum":["FIXED","WEIGHT_BASED"]},"price":{"type":"number"}}},"RemoveCertificateRequest":{"required":["certificateType"],"type":"object","properties":{"certificateType":{"type":"string"},"issuer":{"type":"string"},"validFrom":{"type":"string","format":"date"}}}},"securitySchemes":{"Bearer Authentication":{"type":"http","description":"JWT authentication token obtained from POST /api/auth/login.\n\nFormat: Bearer \n\nExample:\nBearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\n","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/frontend/packages/types/src/generated/api.ts b/frontend/packages/types/src/generated/api.ts index 94c491a..23d5f4f 100644 --- a/frontend/packages/types/src/generated/api.ts +++ b/frontend/packages/types/src/generated/api.ts @@ -356,6 +356,22 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -680,6 +696,22 @@ export interface paths { patch?: 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}": { parameters: { query?: never; @@ -1070,6 +1102,16 @@ export interface components { subRecipeId?: string | null; substitutable: boolean; }; + ProductionStepResponse: { + id: string; + /** Format: int32 */ + stepNumber: number; + description: string; + /** Format: int32 */ + durationMinutes?: number | null; + /** Format: int32 */ + temperatureCelsius?: number | null; + }; RecipeResponse: { id: string; name: string; @@ -1085,11 +1127,21 @@ export interface components { outputUom: string; status: string; ingredients: components["schemas"]["IngredientResponse"][]; + productionSteps: components["schemas"]["ProductionStepResponse"][]; /** Format: date-time */ createdAt: string; /** Format: date-time */ updatedAt: string; }; + AddProductionStepRequest: { + /** Format: int32 */ + stepNumber?: number; + description: string; + /** Format: int32 */ + durationMinutes?: number; + /** Format: int32 */ + temperatureCelsius?: number; + }; AddRecipeIngredientRequest: { /** Format: int32 */ 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: { parameters: { 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: { parameters: { query?: never;