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 a84740b..e0465af 100644 --- a/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java +++ b/backend/src/main/java/de/effigenix/application/production/CreateRecipe.java @@ -26,7 +26,7 @@ public class CreateRecipe { var draft = new RecipeDraft( cmd.name(), cmd.version(), cmd.type(), cmd.description(), cmd.yieldPercentage(), cmd.shelfLifeDays(), - cmd.outputQuantity(), cmd.outputUom() + cmd.outputQuantity(), cmd.outputUom(), cmd.articleId() ); Recipe recipe; diff --git a/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java b/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java index d3d4a76..e72c02b 100644 --- a/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java +++ b/backend/src/main/java/de/effigenix/application/production/command/CreateRecipeCommand.java @@ -10,5 +10,6 @@ public record CreateRecipeCommand( int yieldPercentage, Integer shelfLifeDays, String outputQuantity, - String outputUom + String outputUom, + String articleId ) {} 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 b39d15d..cec635d 100644 --- a/backend/src/main/java/de/effigenix/domain/production/Recipe.java +++ b/backend/src/main/java/de/effigenix/domain/production/Recipe.java @@ -31,6 +31,7 @@ import static de.effigenix.shared.common.Result.*; * 11. Recipe can only be activated when it has at least one ingredient * 12. Recipe can only be activated from DRAFT status * 13. Recipe can only be archived from ACTIVE status + * 14. ArticleId must not be blank (references the output article in Master Data) */ public class Recipe { @@ -42,6 +43,7 @@ public class Recipe { private YieldPercentage yieldPercentage; private Integer shelfLifeDays; private Quantity outputQuantity; + private String articleId; private RecipeStatus status; private final List ingredients; private final List productionSteps; @@ -57,6 +59,7 @@ public class Recipe { YieldPercentage yieldPercentage, Integer shelfLifeDays, Quantity outputQuantity, + String articleId, RecipeStatus status, List ingredients, List productionSteps, @@ -71,6 +74,7 @@ public class Recipe { this.yieldPercentage = yieldPercentage; this.shelfLifeDays = shelfLifeDays; this.outputQuantity = outputQuantity; + this.articleId = articleId; this.status = status; this.ingredients = new ArrayList<>(ingredients); this.productionSteps = new ArrayList<>(productionSteps); @@ -111,7 +115,12 @@ public class Recipe { } } - // 5. Output Quantity (required, positive) + // 5. ArticleId (required) + if (draft.articleId() == null || draft.articleId().isBlank()) { + return Result.failure(new RecipeError.ValidationFailure("ArticleId must not be blank")); + } + + // 6. Output Quantity (required, positive) Quantity outputQuantity; try { BigDecimal amount = new BigDecimal(draft.outputQuantity()); @@ -128,7 +137,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(), List.of(), now, now + draft.articleId(), RecipeStatus.DRAFT, List.of(), List.of(), now, now )); } @@ -144,6 +153,7 @@ public class Recipe { YieldPercentage yieldPercentage, Integer shelfLifeDays, Quantity outputQuantity, + String articleId, RecipeStatus status, List ingredients, List productionSteps, @@ -151,7 +161,7 @@ public class Recipe { OffsetDateTime updatedAt ) { return new Recipe(id, name, version, type, description, - yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt); + yieldPercentage, shelfLifeDays, outputQuantity, articleId, status, ingredients, productionSteps, createdAt, updatedAt); } // ==================== Ingredient Management ==================== @@ -253,6 +263,7 @@ public class Recipe { public YieldPercentage yieldPercentage() { return yieldPercentage; } public Integer shelfLifeDays() { return shelfLifeDays; } public Quantity outputQuantity() { return outputQuantity; } + public String articleId() { return articleId; } public RecipeStatus status() { return status; } public List ingredients() { return Collections.unmodifiableList(ingredients); } public List productionSteps() { return Collections.unmodifiableList(productionSteps); } diff --git a/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java b/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java index 5f72287..5e62194 100644 --- a/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java +++ b/backend/src/main/java/de/effigenix/domain/production/RecipeDraft.java @@ -12,6 +12,7 @@ package de.effigenix.domain.production; * @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE) * @param outputQuantity Expected output quantity amount (required) * @param outputUom Expected output unit of measure (required) + * @param articleId Article ID of the output product (optional; null if not linked) */ public record RecipeDraft( String name, @@ -21,5 +22,6 @@ public record RecipeDraft( int yieldPercentage, Integer shelfLifeDays, String outputQuantity, - String outputUom + String outputUom, + String articleId ) {} 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 4498790..6f0507e 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 @@ -40,6 +40,9 @@ public class RecipeEntity { @Column(name = "output_uom", nullable = false, length = 20) private String outputUom; + @Column(name = "article_id", nullable = false, length = 36) + private String articleId; + @Column(name = "status", nullable = false, length = 20) private String status; @@ -61,7 +64,7 @@ public class RecipeEntity { public RecipeEntity(String id, String name, int version, String type, String description, int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity, - String outputUom, String status, OffsetDateTime createdAt, OffsetDateTime updatedAt) { + String outputUom, String articleId, String status, OffsetDateTime createdAt, OffsetDateTime updatedAt) { this.id = id; this.name = name; this.version = version; @@ -71,6 +74,7 @@ public class RecipeEntity { this.shelfLifeDays = shelfLifeDays; this.outputQuantity = outputQuantity; this.outputUom = outputUom; + this.articleId = articleId; this.status = status; this.createdAt = createdAt; this.updatedAt = updatedAt; @@ -85,6 +89,7 @@ public class RecipeEntity { public Integer getShelfLifeDays() { return shelfLifeDays; } public BigDecimal getOutputQuantity() { return outputQuantity; } public String getOutputUom() { return outputUom; } + public String getArticleId() { return articleId; } public String getStatus() { return status; } public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; } @@ -100,6 +105,7 @@ public class RecipeEntity { public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; } public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; } public void setOutputUom(String outputUom) { this.outputUom = outputUom; } + public void setArticleId(String articleId) { this.articleId = articleId; } public void setStatus(String status) { this.status = status; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } 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 9c149cd..6641e2a 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 @@ -25,6 +25,7 @@ public class RecipeMapper { recipe.shelfLifeDays(), recipe.outputQuantity().amount(), recipe.outputQuantity().uom().name(), + recipe.articleId(), recipe.status().name(), recipe.createdAt(), recipe.updatedAt() @@ -64,6 +65,7 @@ public class RecipeMapper { entity.getOutputQuantity(), UnitOfMeasure.valueOf(entity.getOutputUom()) ), + entity.getArticleId(), RecipeStatus.valueOf(entity.getStatus()), ingredients, productionSteps, 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 cf2ae43..e074873 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 @@ -136,7 +136,7 @@ public class RecipeController { var cmd = new CreateRecipeCommand( request.name(), request.version(), request.type(), request.description(), request.yieldPercentage(), request.shelfLifeDays(), - request.outputQuantity(), request.outputUom() + request.outputQuantity(), request.outputUom(), request.articleId() ); var result = createRecipe.execute(cmd, actorId); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java index 2077f51..52e2443 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateRecipeRequest.java @@ -12,5 +12,6 @@ public record CreateRecipeRequest( int yieldPercentage, Integer shelfLifeDays, @NotBlank String outputQuantity, - @NotBlank String outputUom + @NotBlank String outputUom, + @NotBlank String articleId ) {} 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 b5a0398..339a449 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.OffsetDateTime; import java.util.List; -@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"}) +@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "articleId", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"}) public record RecipeResponse( String id, String name, @@ -17,6 +17,7 @@ public record RecipeResponse( @Schema(nullable = true) Integer shelfLifeDays, String outputQuantity, String outputUom, + String articleId, String status, List ingredients, List productionSteps, @@ -34,6 +35,7 @@ public record RecipeResponse( recipe.shelfLifeDays(), recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().uom().name(), + recipe.articleId(), recipe.status().name(), recipe.ingredients().stream().map(IngredientResponse::from).toList(), recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(), diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java index 380a464..e5e07a8 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/RecipeSummaryResponse.java @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.OffsetDateTime; -@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"}) +@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "articleId", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"}) public record RecipeSummaryResponse( String id, String name, @@ -16,6 +16,7 @@ public record RecipeSummaryResponse( @Schema(nullable = true) Integer shelfLifeDays, String outputQuantity, String outputUom, + String articleId, String status, int ingredientCount, int stepCount, @@ -33,6 +34,7 @@ public record RecipeSummaryResponse( recipe.shelfLifeDays(), recipe.outputQuantity().amount().toPlainString(), recipe.outputQuantity().uom().name(), + recipe.articleId(), recipe.status().name(), recipe.ingredients().size(), recipe.productionSteps().size(), diff --git a/backend/src/main/resources/db/changelog/changes/018-add-article-id-to-recipes.xml b/backend/src/main/resources/db/changelog/changes/018-add-article-id-to-recipes.xml new file mode 100644 index 0000000..e87004a --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/018-add-article-id-to-recipes.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + 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 71b7285..13cae37 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -22,5 +22,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java index 678105b..f7ac104 100644 --- a/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ActivateRecipeTest.java @@ -43,7 +43,7 @@ class ActivateRecipeTest { private Recipe draftRecipeWithIngredient() { var recipe = Recipe.create(new RecipeDraft( "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, - null, 85, 14, "100", "KILOGRAM" + null, 85, 14, "100", "KILOGRAM", "article-123" )).unsafeGetValue(); recipe.addIngredient(new IngredientDraft(1, "article-123", "5.5", "KILOGRAM", null, false)); return recipe; @@ -52,7 +52,7 @@ class ActivateRecipeTest { private Recipe draftRecipeWithoutIngredient() { return Recipe.create(new RecipeDraft( "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, - null, 85, 14, "100", "KILOGRAM" + null, 85, 14, "100", "KILOGRAM", "article-123" )).unsafeGetValue(); } @@ -61,7 +61,7 @@ class ActivateRecipeTest { RecipeId.of("recipe-1"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.ACTIVE, List.of(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } diff --git a/backend/src/test/java/de/effigenix/application/production/AddRecipeIngredientTest.java b/backend/src/test/java/de/effigenix/application/production/AddRecipeIngredientTest.java index dafa653..7401db6 100644 --- a/backend/src/test/java/de/effigenix/application/production/AddRecipeIngredientTest.java +++ b/backend/src/test/java/de/effigenix/application/production/AddRecipeIngredientTest.java @@ -39,7 +39,7 @@ class AddRecipeIngredientTest { private Recipe draftRecipe() { return Recipe.create(new RecipeDraft( "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, - null, 85, 14, "100", "KILOGRAM" + null, 85, 14, "100", "KILOGRAM", "article-123" )).unsafeGetValue(); } diff --git a/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java index eca7cf0..55485a0 100644 --- a/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ArchiveRecipeTest.java @@ -45,7 +45,7 @@ class ArchiveRecipeTest { RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.ACTIVE, List.of(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } @@ -53,7 +53,7 @@ class ArchiveRecipeTest { private Recipe draftRecipe() { return Recipe.create(new RecipeDraft( "Bratwurst", 1, RecipeType.FINISHED_PRODUCT, - null, 85, 14, "100", "KILOGRAM" + null, 85, 14, "100", "KILOGRAM", "article-123" )).unsafeGetValue(); } @@ -118,7 +118,7 @@ class ArchiveRecipeTest { RecipeId.of("recipe-2"), new RecipeName("Test"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.ARCHIVED, List.of(), List.of(), + "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); when(authPort.can(performedBy, ProductionAction.RECIPE_WRITE)).thenReturn(true); diff --git a/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java b/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java index cb2348e..ee0004c 100644 --- a/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java +++ b/backend/src/test/java/de/effigenix/application/production/GetRecipeTest.java @@ -43,7 +43,7 @@ class GetRecipeTest { RecipeId.of(id), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, "Beschreibung", new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.ACTIVE, List.of(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } diff --git a/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java b/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java index 86ffd38..fea301d 100644 --- a/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ListRecipesTest.java @@ -42,7 +42,7 @@ class ListRecipesTest { RecipeId.of(id), new RecipeName("Rezept-" + id), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - status, List.of(), List.of(), + "article-123", status, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } diff --git a/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java b/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java index 7190a1c..73a0141 100644 --- a/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/PlanBatchTest.java @@ -57,7 +57,7 @@ class PlanBatchTest { RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.ACTIVE, List.of(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } @@ -67,7 +67,7 @@ class PlanBatchTest { RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(85), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.DRAFT, List.of(), List.of(), + "article-123", RecipeStatus.DRAFT, List.of(), List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) ); } diff --git a/backend/src/test/java/de/effigenix/application/production/RecipeCycleCheckerTest.java b/backend/src/test/java/de/effigenix/application/production/RecipeCycleCheckerTest.java index a79b485..86dfe84 100644 --- a/backend/src/test/java/de/effigenix/application/production/RecipeCycleCheckerTest.java +++ b/backend/src/test/java/de/effigenix/application/production/RecipeCycleCheckerTest.java @@ -48,7 +48,7 @@ class RecipeCycleCheckerTest { RecipeId.of(id), new RecipeName("Recipe-" + id), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(100), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.DRAFT, ingredients, List.of(), + "article-123", RecipeStatus.DRAFT, ingredients, List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)); } @@ -145,7 +145,7 @@ class RecipeCycleCheckerTest { RecipeId.of("B"), new RecipeName("Recipe-B"), 1, RecipeType.FINISHED_PRODUCT, null, new YieldPercentage(100), 14, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), - RecipeStatus.DRAFT, ingredients, List.of(), + "article-123", RecipeStatus.DRAFT, ingredients, List.of(), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC)); when(recipeRepository.findById(RecipeId.of("B"))).thenReturn(Result.success(Optional.of(recipeB))); 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 ca416c9..09f7774 100644 --- a/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/RecipeTest.java @@ -17,7 +17,7 @@ class RecipeTest { return new RecipeDraft( "Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT, "Grobe Bratwurst nach Hausrezept", 85, - 14, "100", "KILOGRAM" + 14, "100", "KILOGRAM", "article-123" ); } @@ -56,7 +56,7 @@ class RecipeTest { @DisplayName("should fail when name is blank") void should_Fail_When_NameIsBlank() { var draft = new RecipeDraft( - "", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + "", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -69,7 +69,7 @@ class RecipeTest { @DisplayName("should fail when name is null") void should_Fail_When_NameIsNull() { var draft = new RecipeDraft( - null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -82,7 +82,7 @@ class RecipeTest { @DisplayName("should fail when version is less than 1") void should_Fail_When_VersionLessThanOne() { var draft = new RecipeDraft( - "Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + "Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -95,7 +95,7 @@ class RecipeTest { @DisplayName("should fail when yield percentage is 0") void should_Fail_When_YieldPercentageIsZero() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -108,7 +108,7 @@ class RecipeTest { @DisplayName("should fail when yield percentage is 201") void should_Fail_When_YieldPercentageIs201() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -121,7 +121,7 @@ class RecipeTest { @DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT") void should_Fail_When_ShelfLifeZeroForFinishedProduct() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -134,7 +134,7 @@ class RecipeTest { @DisplayName("should fail when shelf life is null for FINISHED_PRODUCT") void should_Fail_When_ShelfLifeNullForFinishedProduct() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -147,7 +147,7 @@ class RecipeTest { @DisplayName("should fail when shelf life is 0 for INTERMEDIATE") void should_Fail_When_ShelfLifeZeroForIntermediate() { var draft = new RecipeDraft( - "Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM" + "Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -160,7 +160,7 @@ class RecipeTest { @DisplayName("should allow null shelf life for RAW_MATERIAL") void should_AllowNullShelfLife_When_RawMaterial() { var draft = new RecipeDraft( - "Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM" + "Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -173,7 +173,7 @@ class RecipeTest { @DisplayName("should fail when output quantity is zero") void should_Fail_When_OutputQuantityZero() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -186,7 +186,7 @@ class RecipeTest { @DisplayName("should fail when output UoM is invalid") void should_Fail_When_OutputUomInvalid() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID", "article-123" ); var result = Recipe.create(draft); @@ -199,7 +199,7 @@ class RecipeTest { @DisplayName("should allow description to be null") void should_AllowNullDescription() { var draft = new RecipeDraft( - "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM" + "Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM", "article-123" ); var result = Recipe.create(draft); @@ -238,7 +238,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -321,7 +321,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -375,7 +375,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -444,7 +444,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -506,7 +506,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -527,7 +527,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.ARCHIVED, List.of(), List.of(), + "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -553,7 +553,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(), List.of(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); var updatedBefore = recipe.updatedAt(); @@ -587,7 +587,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.ARCHIVED, List.of(), List.of(), + "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC), java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC) ); @@ -614,7 +614,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(), List.of(), recipe1.createdAt(), recipe1.updatedAt() + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), recipe1.createdAt(), recipe1.updatedAt() ); assertThat(recipe1).isEqualTo(recipe2); diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java index fc98700..a5244d8 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java @@ -312,7 +312,8 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { "yieldPercentage": 85, "shelfLifeDays": 14, "outputQuantity": "100", - "outputUom": "KILOGRAM" + "outputUom": "KILOGRAM", + "articleId": "article-123" } """.formatted(UUID.randomUUID().toString().substring(0, 8)); diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java index 334c9c4..01d1184 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/GetRecipeIntegrationTest.java @@ -82,7 +82,8 @@ class GetRecipeIntegrationTest extends AbstractIntegrationTest { "yieldPercentage": 85, "shelfLifeDays": 14, "outputQuantity": "100", - "outputUom": "KILOGRAM" + "outputUom": "KILOGRAM", + "articleId": "article-123" } """; diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java index 43457fe..795c79f 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ListRecipesIntegrationTest.java @@ -113,7 +113,8 @@ class ListRecipesIntegrationTest extends AbstractIntegrationTest { "yieldPercentage": 85, "shelfLifeDays": 14, "outputQuantity": "100", - "outputUom": "KILOGRAM" + "outputUom": "KILOGRAM", + "articleId": "article-123" } """.formatted(name, version); diff --git a/docs/tickets/recipe-reorder-backend.md b/docs/tickets/recipe-reorder-backend.md new file mode 100644 index 0000000..63db9d4 --- /dev/null +++ b/docs/tickets/recipe-reorder-backend.md @@ -0,0 +1,53 @@ +# Backend: Atomarer Reorder-Endpoint für Rezept-Zutaten + +## Kontext + +Aktuell wird die Umsortierung von Rezept-Zutaten im Frontend durch sequentielles Entfernen und Neu-Hinzufügen aller Zutaten realisiert (Remove + Re-Add). Das ist fehleranfällig und nicht atomar. + +## Anforderung + +Neuer Endpoint: + +``` +PUT /api/recipes/{id}/ingredients/reorder +``` + +### Request Body + +```json +{ + "ingredientOrder": [ + { "ingredientId": "uuid-1", "position": 1 }, + { "ingredientId": "uuid-2", "position": 2 }, + { "ingredientId": "uuid-3", "position": 3 } + ] +} +``` + +### Verhalten + +- Alle `ingredientId`s muessen zum Rezept gehoeren +- Positionen muessen lueckenlos ab 1 aufsteigend sein +- Nur bei DRAFT-Status erlaubt +- Atomare Operation (eine Transaktion) +- Gibt das aktualisierte `RecipeResponse` zurueck + +### Fehler + +- `404` wenn Rezept nicht existiert +- `400` wenn IDs nicht zum Rezept gehoeren oder Positionen ungueltig +- `409` wenn Rezept nicht im DRAFT-Status + +## Betroffene Schichten + +- **Domain**: `Recipe.reorderIngredients(List)` -> `Result` +- **Application**: `ReorderIngredients` Use Case + `ReorderIngredientsCommand` +- **Infrastructure**: Controller-Endpoint + Request-DTO + +## Frontend-Anpassung + +Nach Backend-Implementierung kann `RecipeDetailScreen.tsx` den Remove+Re-Add-Workaround durch einen einzelnen API-Call ersetzen: + +```ts +await client.recipes.reorderIngredients(recipeId, { ingredientOrder: [...] }); +``` diff --git a/frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx b/frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx index 0197751..0365977 100644 --- a/frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/AddBatchScreen.tsx @@ -6,8 +6,8 @@ import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; import { SuccessDisplay } from '../shared/SuccessDisplay.js'; import { useStocks } from '../../hooks/useStocks.js'; -import { BATCH_TYPE_LABELS } from '@effigenix/api-client'; -import type { BatchType } from '@effigenix/api-client'; +import { BATCH_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client'; +import type { BatchType, UoM } from '@effigenix/api-client'; type Field = 'batchId' | 'batchType' | 'quantityAmount' | 'quantityUnit' | 'expiryDate'; const FIELDS: Field[] = ['batchId', 'batchType', 'quantityAmount', 'quantityUnit', 'expiryDate']; @@ -16,29 +16,65 @@ const FIELD_LABELS: Record = { batchId: 'Chargen-Nr. *', batchType: 'Chargentyp *', quantityAmount: 'Menge *', - quantityUnit: 'Einheit *', + quantityUnit: 'Einheit * (←→ wechseln)', expiryDate: 'Ablaufdatum (YYYY-MM-DD) *', }; const BATCH_TYPES: BatchType[] = ['PURCHASED', 'PRODUCED']; export function AddBatchScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const stockId = params['stockId'] ?? ''; const { addBatch, loading, error, clearError } = useStocks(); - const [values, setValues] = useState>({ - batchId: '', batchType: 'PURCHASED', quantityAmount: '', quantityUnit: '', expiryDate: '', + const [values, setValues] = useState, string>>({ + batchId: '', batchType: 'PURCHASED', quantityAmount: '', expiryDate: '', }); + const [uomIdx, setUomIdx] = useState(0); const [activeField, setActiveField] = useState('batchId'); const [fieldErrors, setFieldErrors] = useState>>({}); const [success, setSuccess] = useState(null); - const setField = (field: Field) => (value: string) => { + const setField = (field: Exclude) => (value: string) => { setValues((v) => ({ ...v, [field]: value })); }; + const handleSubmit = async () => { + const errors: Partial> = {}; + if (!values.batchId.trim()) errors.batchId = 'Chargen-Nr. ist erforderlich.'; + if (!values.quantityAmount.trim()) errors.quantityAmount = 'Menge ist erforderlich.'; + if (values.quantityAmount.trim() && isNaN(Number(values.quantityAmount))) errors.quantityAmount = 'Muss eine Zahl sein.'; + if (!values.expiryDate.trim()) errors.expiryDate = 'Ablaufdatum ist erforderlich.'; + if (values.expiryDate.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(values.expiryDate.trim())) { + errors.expiryDate = 'Format: YYYY-MM-DD'; + } + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + + const selectedUom = UOM_VALUES[uomIdx] as UoM; + const batch = await addBatch(stockId, { + batchId: values.batchId.trim(), + batchType: values.batchType, + quantityAmount: values.quantityAmount.trim(), + quantityUnit: selectedUom, + expiryDate: values.expiryDate.trim(), + }); + + if (batch) { + setSuccess(`Charge ${batch.batchId} erfolgreich eingebucht.`); + } + }; + + const handleFieldSubmit = (field: Field) => (_value: string) => { + const idx = FIELDS.indexOf(field); + if (idx < FIELDS.length - 1) { + setActiveField(FIELDS[idx + 1] ?? field); + } else { + void handleSubmit(); + } + }; + useInput((_input, key) => { if (loading) return; @@ -51,6 +87,22 @@ export function AddBatchScreen() { if (next) setValues((v) => ({ ...v, batchType: next })); return; } + if (key.return) { + handleFieldSubmit('batchType')(''); + return; + } + } + + if (activeField === 'quantityUnit') { + if (key.leftArrow || key.rightArrow) { + const dir = key.rightArrow ? 1 : -1; + setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length); + return; + } + if (key.return) { + handleFieldSubmit('quantityUnit')(''); + return; + } } if (key.tab || key.downArrow) { @@ -68,49 +120,16 @@ export function AddBatchScreen() { if (key.escape) back(); }); - const handleSubmit = async () => { - const errors: Partial> = {}; - if (!values.batchId.trim()) errors.batchId = 'Chargen-Nr. ist erforderlich.'; - if (!values.quantityAmount.trim()) errors.quantityAmount = 'Menge ist erforderlich.'; - if (values.quantityAmount.trim() && isNaN(Number(values.quantityAmount))) errors.quantityAmount = 'Muss eine Zahl sein.'; - if (!values.quantityUnit.trim()) errors.quantityUnit = 'Einheit ist erforderlich.'; - if (!values.expiryDate.trim()) errors.expiryDate = 'Ablaufdatum ist erforderlich.'; - if (values.expiryDate.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(values.expiryDate.trim())) { - errors.expiryDate = 'Format: YYYY-MM-DD'; - } - setFieldErrors(errors); - if (Object.keys(errors).length > 0) return; - - const batch = await addBatch(stockId, { - batchId: values.batchId.trim(), - batchType: values.batchType, - quantityAmount: values.quantityAmount.trim(), - quantityUnit: values.quantityUnit.trim(), - expiryDate: values.expiryDate.trim(), - }); - - if (batch) { - setSuccess(`Charge ${batch.batchId} erfolgreich eingebucht.`); - } - }; - - const handleFieldSubmit = (field: Field) => (_value: string) => { - const idx = FIELDS.indexOf(field); - if (idx < FIELDS.length - 1) { - setActiveField(FIELDS[idx + 1] ?? field); - } else { - void handleSubmit(); - } - }; - if (!stockId) return ; if (loading) return ; + const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx]; + return ( Charge einbuchen {error && } - {success && navigate('inventory-menu')} />} + {success && replace('inventory-menu')} />} {!success && ( @@ -120,7 +139,16 @@ export function AddBatchScreen() { return ( - {FIELD_LABELS[field]}: {activeField === field ? `← ${typeName} →` : typeName} + {FIELD_LABELS[field]}: {activeField === field ? `< ${typeName} >` : typeName} + + + ); + } + if (field === 'quantityUnit') { + return ( + + + {FIELD_LABELS[field]}: {activeField === field ? `< ${uomLabel} >` : uomLabel} ); @@ -129,8 +157,8 @@ export function AddBatchScreen() { ]} + onChange={setField(field as Exclude)} onSubmit={handleFieldSubmit(field)} focus={activeField === field} {...(fieldErrors[field] ? { error: fieldErrors[field] } : {})} @@ -142,7 +170,7 @@ export function AddBatchScreen() { - Tab/↑↓ Feld wechseln · ←→ Typ wählen · Enter auf letztem Feld speichern · Escape Abbrechen + Tab/↑↓ Feld wechseln · ←→ Typ/Einheit wählen · Enter auf letztem Feld speichern · Escape Abbrechen diff --git a/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx b/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx index eb3edba..c3ae9fd 100644 --- a/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/StockBatchEntryScreen.tsx @@ -14,7 +14,7 @@ export function StockBatchEntryScreen() { }; useInput((_input, key) => { - if (key.escape) back(); + if (key.escape || key.backspace) back(); }); const handleSubmit = () => { diff --git a/frontend/apps/cli/src/components/inventory/StorageLocationCreateScreen.tsx b/frontend/apps/cli/src/components/inventory/StorageLocationCreateScreen.tsx index a12fb25..3582acf 100644 --- a/frontend/apps/cli/src/components/inventory/StorageLocationCreateScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/StorageLocationCreateScreen.tsx @@ -21,7 +21,7 @@ const FIELD_LABELS: Record = { const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA']; export function StorageLocationCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createStorageLocation, loading, error, clearError } = useStorageLocations(); const [values, setValues] = useState>({ @@ -78,7 +78,7 @@ export function StorageLocationCreateScreen() { ...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.trim() } : {}), ...(values.maxTemperature.trim() ? { maxTemperature: values.maxTemperature.trim() } : {}), }); - if (result) navigate('storage-location-list'); + if (result) replace('storage-location-list'); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx index 2d4084f..22c6a89 100644 --- a/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx @@ -17,7 +17,7 @@ const UNIT_PRICE_MODEL: Record = { }; export function AddSalesUnitScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const articleId = params['articleId'] ?? ''; const { addSalesUnit, loading, error, clearError } = useArticles(); @@ -50,7 +50,7 @@ export function AddSalesUnitScreen() { } setPriceError(null); const updated = await addSalesUnit(articleId, { unit: selectedUnit, priceModel: autoModel, price: priceNum }); - if (updated) navigate('article-detail', { articleId }); + if (updated) replace('article-detail', { articleId }); }; if (loading) return ; diff --git a/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx index 3291af5..ea343ed 100644 --- a/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx @@ -23,7 +23,7 @@ const UNIT_PRICE_MODEL: Record = { }; export function ArticleCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createArticle, loading, error, clearError } = useArticles(); const { categories, fetchCategories } = useCategories(); @@ -94,7 +94,7 @@ export function ArticleCreateScreen() { priceModel: autoModel, price: priceNum, }); - if (result) navigate('article-list'); + if (result) replace('article-list'); }; if (loading) return ; diff --git a/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx index 69fbb3b..653ae7d 100644 --- a/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx @@ -10,7 +10,7 @@ type Field = 'name' | 'description'; const FIELDS: Field[] = ['name', 'description']; export function CategoryCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createCategory, loading, error, clearError } = useCategories(); const [name, setName] = useState(''); @@ -42,7 +42,7 @@ export function CategoryCreateScreen() { return; } const cat = await createCategory(name.trim(), description.trim() || undefined); - if (cat) navigate('category-list'); + if (cat) replace('category-list'); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx index cb2ebbc..2ff391d 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx @@ -21,7 +21,7 @@ const FIELD_LABELS: Record = { }; export function AddDeliveryAddressScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const customerId = params['customerId'] ?? ''; const { addDeliveryAddress, loading, error, clearError } = useCustomers(); @@ -74,7 +74,7 @@ export function AddDeliveryAddressScreen() { ...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}), ...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}), }); - if (updated) navigate('customer-detail', { customerId }); + if (updated) replace('customer-detail', { customerId }); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx index 53dbbb2..063c872 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx @@ -25,7 +25,7 @@ const FIELD_LABELS: Record = { const TYPES: CustomerType[] = ['B2B', 'B2C']; export function CustomerCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createCustomer, loading, error, clearError } = useCustomers(); const [values, setValues] = useState>({ @@ -91,7 +91,7 @@ export function CustomerCreateScreen() { ...(values.email.trim() ? { email: values.email.trim() } : {}), ...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}), }); - if (result) navigate('customer-list'); + if (result) replace('customer-list'); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx index d5862a9..aa40293 100644 --- a/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx @@ -17,7 +17,7 @@ function errorMessage(err: unknown): string { } export function SetPreferencesScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const customerId = params['customerId'] ?? ''; const { setPreferences, loading, error, clearError } = useCustomers(); @@ -53,8 +53,8 @@ export function SetPreferencesScreen() { const handleSave = useCallback(async () => { const updated = await setPreferences(customerId, Array.from(checked)); - if (updated) navigate('customer-detail', { customerId }); - }, [customerId, checked, setPreferences, navigate]); + if (updated) replace('customer-detail', { customerId }); + }, [customerId, checked, setPreferences, replace]); if (initLoading) return ; if (initError) return ; diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx index b113131..7c04fbc 100644 --- a/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx @@ -21,7 +21,7 @@ function isValidDate(s: string): boolean { } export function AddCertificateScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const supplierId = params['supplierId'] ?? ''; const { addCertificate, loading, error, clearError } = useSuppliers(); @@ -70,7 +70,7 @@ export function AddCertificateScreen() { validFrom: values.validFrom, validUntil: values.validUntil, }); - if (updated) navigate('supplier-detail', { supplierId }); + if (updated) replace('supplier-detail', { supplierId }); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx index 012ac26..7b80d29 100644 --- a/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx @@ -32,7 +32,7 @@ function ScoreSelector({ label, value, active }: { label: string; value: number; } export function RateSupplierScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const supplierId = params['supplierId'] ?? ''; const { rateSupplier, loading, error, clearError } = useSuppliers(); @@ -81,7 +81,7 @@ export function RateSupplierScreen() { }); if (updated) { setSuccessMessage('Bewertung gespeichert.'); - setTimeout(() => navigate('supplier-detail', { supplierId }), 1000); + setTimeout(() => replace('supplier-detail', { supplierId }), 1000); } }; diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx index a786407..3479c49 100644 --- a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx +++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx @@ -23,7 +23,7 @@ const FIELD_LABELS: Record = { }; export function SupplierCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createSupplier, loading, error, clearError } = useSuppliers(); const [values, setValues] = useState>({ @@ -74,7 +74,7 @@ export function SupplierCreateScreen() { ...(values.country.trim() ? { country: values.country.trim() } : {}), ...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}), }); - if (result) navigate('supplier-list'); + if (result) replace('supplier-list'); }; const handleFieldSubmit = (field: Field) => (_value: string) => { diff --git a/frontend/apps/cli/src/components/production/AddIngredientScreen.tsx b/frontend/apps/cli/src/components/production/AddIngredientScreen.tsx index 71856d7..921ffa1 100644 --- a/frontend/apps/cli/src/components/production/AddIngredientScreen.tsx +++ b/frontend/apps/cli/src/components/production/AddIngredientScreen.tsx @@ -1,19 +1,22 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import { useNavigation } from '../../state/navigation-context.js'; import { FormInput } from '../shared/FormInput.js'; +import { ArticlePicker } from '../shared/ArticlePicker.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; +import { useArticles } from '../../hooks/useArticles.js'; +import { UOM_VALUES, UOM_LABELS } from '@effigenix/api-client'; +import type { UoM, RecipeDTO, ArticleDTO } from '@effigenix/api-client'; import { client } from '../../utils/api-client.js'; -type Field = 'position' | 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable'; -const FIELDS: Field[] = ['position', 'articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable']; +type Field = 'articleId' | 'quantity' | 'uom' | 'subRecipeId' | 'substitutable'; +const FIELDS: Field[] = ['articleId', 'quantity', 'uom', 'subRecipeId', 'substitutable']; const FIELD_LABELS: Record = { - position: 'Position (optional)', - articleId: 'Artikel-ID *', + articleId: 'Artikel *', quantity: 'Menge *', - uom: 'Einheit *', + uom: 'Einheit * (←→ wechseln)', subRecipeId: 'Sub-Rezept-ID (optional)', substitutable: 'Austauschbar (ja/nein, optional)', }; @@ -23,65 +26,81 @@ function errorMessage(err: unknown): string { } export function AddIngredientScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const recipeId = params['recipeId'] ?? ''; - const [values, setValues] = useState>({ - position: '', articleId: '', quantity: '', uom: '', subRecipeId: '', substitutable: '', + const { articles, fetchArticles } = useArticles(); + + const [recipe, setRecipe] = useState(null); + const [recipeLoading, setRecipeLoading] = useState(true); + const [values, setValues] = useState({ + quantity: '', + subRecipeId: '', + substitutable: '', }); - const [activeField, setActiveField] = useState('position'); + const [uomIdx, setUomIdx] = useState(0); + const [articleQuery, setArticleQuery] = useState(''); + const [selectedArticle, setSelectedArticle] = useState<{ id: string; name: string; articleNumber: string } | null>(null); + const [activeField, setActiveField] = useState('articleId'); const [fieldErrors, setFieldErrors] = useState>>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const setField = (field: Field) => (value: string) => { + useEffect(() => { + if (!recipeId) return; + void fetchArticles(); + client.recipes.getById(recipeId) + .then((r) => { setRecipe(r); setRecipeLoading(false); }) + .catch((err: unknown) => { setError(errorMessage(err)); setRecipeLoading(false); }); + }, [recipeId, fetchArticles]); + + const setField = (field: 'quantity' | 'subRecipeId' | 'substitutable') => (value: string) => { setValues((v) => ({ ...v, [field]: value })); }; - useInput((_input, key) => { - if (loading) return; - if (key.tab || key.downArrow) { - setActiveField((f) => { - const idx = FIELDS.indexOf(f); - return FIELDS[(idx + 1) % FIELDS.length] ?? f; - }); - } - if (key.upArrow) { - setActiveField((f) => { - const idx = FIELDS.indexOf(f); - return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; - }); - } - if (key.escape) back(); - }); + const handleArticleSelect = useCallback((article: ArticleDTO) => { + setSelectedArticle({ id: article.id, name: article.name, articleNumber: article.articleNumber }); + setArticleQuery(''); + setActiveField('quantity'); + + void client.recipes.list().then((recipes) => { + const linked = recipes.find((r) => r.articleId === article.id && r.status === 'ACTIVE') + ?? recipes.find((r) => r.articleId === article.id); + if (linked) { + setValues((v) => ({ ...v, subRecipeId: linked.id })); + } + }).catch(() => { /* Rezept-Lookup fehlgeschlagen – subRecipeId bleibt leer */ }); + }, []); const handleSubmit = async () => { const errors: Partial> = {}; - if (!values.articleId.trim()) errors.articleId = 'Artikel-ID ist erforderlich.'; + if (!selectedArticle) errors.articleId = 'Artikel ist erforderlich.'; if (!values.quantity.trim()) errors.quantity = 'Menge ist erforderlich.'; if (values.quantity.trim() && isNaN(Number(values.quantity))) errors.quantity = 'Muss eine Zahl sein.'; - if (!values.uom.trim()) errors.uom = 'Einheit ist erforderlich.'; - if (values.position.trim() && (!Number.isInteger(Number(values.position)) || Number(values.position) < 1)) { - errors.position = 'Muss eine positive Ganzzahl sein.'; - } if (values.substitutable.trim() && !['ja', 'nein'].includes(values.substitutable.trim().toLowerCase())) { errors.substitutable = 'Muss "ja" oder "nein" sein.'; } setFieldErrors(errors); if (Object.keys(errors).length > 0) return; + const maxPosition = recipe?.ingredients?.length + ? Math.max(...recipe.ingredients.map((i) => i.position)) + : 0; + + const selectedUom = UOM_VALUES[uomIdx] as UoM; + setLoading(true); setError(null); try { await client.recipes.addIngredient(recipeId, { - articleId: values.articleId.trim(), + articleId: selectedArticle!.id, quantity: values.quantity.trim(), - uom: values.uom.trim(), - ...(values.position.trim() ? { position: Number(values.position) } : {}), + uom: selectedUom, + position: maxPosition + 1, ...(values.subRecipeId.trim() ? { subRecipeId: values.subRecipeId.trim() } : {}), ...(values.substitutable.trim() ? { substitutable: values.substitutable.trim().toLowerCase() === 'ja' } : {}), }); - navigate('recipe-detail', { recipeId }); + replace('recipe-detail', { recipeId }); } catch (err: unknown) { setError(errorMessage(err)); setLoading(false); @@ -97,31 +116,93 @@ export function AddIngredientScreen() { } }; + useInput((_input, key) => { + if (loading || recipeLoading) return; + if (activeField === 'articleId') return; + + if (activeField === 'uom') { + if (key.leftArrow || key.rightArrow) { + const dir = key.rightArrow ? 1 : -1; + setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length); + return; + } + if (key.return) { + handleFieldSubmit('uom')(''); + return; + } + } + + if (key.tab || key.downArrow) { + setActiveField((f) => { + const idx = FIELDS.indexOf(f); + return FIELDS[(idx + 1) % FIELDS.length] ?? f; + }); + } + if (key.upArrow) { + setActiveField((f) => { + const idx = FIELDS.indexOf(f); + return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; + }); + } + if (key.escape) back(); + }); + if (!recipeId) return ; + if (recipeLoading) return ; if (loading) return ; + const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx]; + const selectedName = selectedArticle ? `${selectedArticle.name} (${selectedArticle.articleNumber})` : undefined; + return ( Zutat hinzufügen {error && setError(null)} />} - {FIELDS.map((field) => ( - - ))} + {FIELDS.map((field) => { + if (field === 'articleId') { + return ( + + + {fieldErrors.articleId && ⚠ {fieldErrors.articleId}} + + ); + } + if (field === 'uom') { + return ( + + + {FIELD_LABELS[field]}: {activeField === field ? `< ${uomLabel} >` : uomLabel} + + + ); + } + const textField = field as 'quantity' | 'subRecipeId' | 'substitutable'; + return ( + + ); + })} - Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen + Tab/↑↓ Feld wechseln · ←→ Einheit · Artikelsuche tippen · Enter auf letztem Feld speichern · Escape Abbrechen diff --git a/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx b/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx index fb2fa50..0b642c5 100644 --- a/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx +++ b/frontend/apps/cli/src/components/production/AddProductionStepScreen.tsx @@ -21,7 +21,7 @@ function errorMessage(err: unknown): string { } export function AddProductionStepScreen() { - const { params, navigate, back } = useNavigation(); + const { params, replace, back } = useNavigation(); const recipeId = params['recipeId'] ?? ''; const [values, setValues] = useState>({ @@ -77,7 +77,7 @@ export function AddProductionStepScreen() { ...(values.durationMinutes.trim() ? { durationMinutes: Number(values.durationMinutes) } : {}), ...(values.temperatureCelsius.trim() ? { temperatureCelsius: Number(values.temperatureCelsius) } : {}), }); - navigate('recipe-detail', { recipeId }); + replace('recipe-detail', { recipeId }); } catch (err: unknown) { setError(errorMessage(err)); setLoading(false); diff --git a/frontend/apps/cli/src/components/production/RecipeCreateScreen.tsx b/frontend/apps/cli/src/components/production/RecipeCreateScreen.tsx index d77797e..24199ee 100644 --- a/frontend/apps/cli/src/components/production/RecipeCreateScreen.tsx +++ b/frontend/apps/cli/src/components/production/RecipeCreateScreen.tsx @@ -1,15 +1,17 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; import { useNavigation } from '../../state/navigation-context.js'; import { useRecipes } from '../../hooks/useRecipes.js'; +import { useArticles } from '../../hooks/useArticles.js'; import { FormInput } from '../shared/FormInput.js'; +import { ArticlePicker } from '../shared/ArticlePicker.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; import { ErrorDisplay } from '../shared/ErrorDisplay.js'; -import { RECIPE_TYPE_LABELS } from '@effigenix/api-client'; -import type { RecipeType } from '@effigenix/api-client'; +import { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from '@effigenix/api-client'; +import type { RecipeType, UoM, ArticleDTO } from '@effigenix/api-client'; -type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom'; -const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom']; +type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom' | 'articleId'; +const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom', 'articleId']; const FIELD_LABELS: Record = { name: 'Name *', @@ -19,16 +21,20 @@ const FIELD_LABELS: Record = { yieldPercentage: 'Ausbeute (%) *', shelfLifeDays: 'Haltbarkeit (Tage)', outputQuantity: 'Ausgabemenge *', - outputUom: 'Mengeneinheit *', + outputUom: 'Mengeneinheit * (←→ wechseln)', + articleId: 'Artikel *', }; const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT']; -export function RecipeCreateScreen() { - const { navigate, back } = useNavigation(); - const { createRecipe, loading, error, clearError } = useRecipes(); +type TextFields = Exclude; - const [values, setValues] = useState>({ +export function RecipeCreateScreen() { + const { replace, back } = useNavigation(); + const { createRecipe, loading, error, clearError } = useRecipes(); + const { articles, fetchArticles } = useArticles(); + + const [values, setValues] = useState>({ name: '', version: '1', type: 'FINISHED_PRODUCT', @@ -36,17 +42,68 @@ export function RecipeCreateScreen() { yieldPercentage: '100', shelfLifeDays: '', outputQuantity: '', - outputUom: '', }); + const [uomIdx, setUomIdx] = useState(0); + const [articleQuery, setArticleQuery] = useState(''); + const [selectedArticle, setSelectedArticle] = useState<{ id: string; name: string } | null>(null); const [activeField, setActiveField] = useState('name'); const [fieldErrors, setFieldErrors] = useState>>({}); - const setField = (field: Field) => (value: string) => { + useEffect(() => { void fetchArticles(); }, []); + + const setField = (field: TextFields) => (value: string) => { setValues((v) => ({ ...v, [field]: value })); }; - useInput((input, key) => { + const handleSubmit = async () => { + const errors: Partial> = {}; + if (!values.name.trim()) errors.name = 'Name ist erforderlich.'; + if (!values.version.trim() || isNaN(parseInt(values.version, 10))) errors.version = 'Version muss eine Zahl sein.'; + if (!values.type) errors.type = 'Rezepttyp ist erforderlich.'; + if (!values.yieldPercentage.trim() || isNaN(parseInt(values.yieldPercentage, 10))) errors.yieldPercentage = 'Ausbeute muss eine Zahl sein.'; + if (!values.outputQuantity.trim()) errors.outputQuantity = 'Ausgabemenge ist erforderlich.'; + if (!selectedArticle) errors.articleId = 'Artikel muss ausgewählt werden.'; + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + + const selectedUom = UOM_VALUES[uomIdx] as UoM; + const result = await createRecipe({ + name: values.name.trim(), + version: parseInt(values.version, 10), + type: values.type as RecipeType, + ...(values.description.trim() ? { description: values.description.trim() } : {}), + yieldPercentage: parseInt(values.yieldPercentage, 10), + ...(values.shelfLifeDays.trim() ? { shelfLifeDays: parseInt(values.shelfLifeDays, 10) } : {}), + outputQuantity: values.outputQuantity.trim(), + outputUom: selectedUom, + articleId: selectedArticle!.id, + }); + if (result) replace('recipe-list'); + }; + + const handleFieldSubmit = (field: Field) => (_value: string) => { + const idx = FIELDS.indexOf(field); + if (idx < FIELDS.length - 1) { + setActiveField(FIELDS[idx + 1] ?? field); + } else { + void handleSubmit(); + } + }; + + const handleArticleSelect = (article: ArticleDTO) => { + setSelectedArticle({ id: article.id, name: `${article.name} (${article.articleNumber})` }); + setArticleQuery(''); + }; + + useInput((_input, key) => { if (loading) return; + if (activeField === 'articleId') { + if (key.escape) back(); + if (key.tab || key.downArrow) setActiveField(FIELDS[0] ?? 'name'); + if (key.upArrow) setActiveField(FIELDS[FIELDS.length - 2] ?? 'outputUom'); + if (key.return && selectedArticle && !articleQuery) void handleSubmit(); + return; + } if (activeField === 'type') { if (key.leftArrow || key.rightArrow) { @@ -56,6 +113,22 @@ export function RecipeCreateScreen() { if (next) setValues((v) => ({ ...v, type: next })); return; } + if (key.return) { + handleFieldSubmit('type')(''); + return; + } + } + + if (activeField === 'outputUom') { + if (key.leftArrow || key.rightArrow) { + const dir = key.rightArrow ? 1 : -1; + setUomIdx((i) => (i + dir + UOM_VALUES.length) % UOM_VALUES.length); + return; + } + if (key.return) { + handleFieldSubmit('outputUom')(''); + return; + } } if (key.tab || key.downArrow) { @@ -73,39 +146,6 @@ export function RecipeCreateScreen() { if (key.escape) back(); }); - const handleSubmit = async () => { - const errors: Partial> = {}; - if (!values.name.trim()) errors.name = 'Name ist erforderlich.'; - if (!values.version.trim() || isNaN(parseInt(values.version, 10))) errors.version = 'Version muss eine Zahl sein.'; - if (!values.type) errors.type = 'Rezepttyp ist erforderlich.'; - if (!values.yieldPercentage.trim() || isNaN(parseInt(values.yieldPercentage, 10))) errors.yieldPercentage = 'Ausbeute muss eine Zahl sein.'; - if (!values.outputQuantity.trim()) errors.outputQuantity = 'Ausgabemenge ist erforderlich.'; - if (!values.outputUom.trim()) errors.outputUom = 'Mengeneinheit ist erforderlich.'; - setFieldErrors(errors); - if (Object.keys(errors).length > 0) return; - - const result = await createRecipe({ - name: values.name.trim(), - version: parseInt(values.version, 10), - type: values.type as RecipeType, - ...(values.description.trim() ? { description: values.description.trim() } : {}), - yieldPercentage: parseInt(values.yieldPercentage, 10), - ...(values.shelfLifeDays.trim() ? { shelfLifeDays: parseInt(values.shelfLifeDays, 10) } : {}), - outputQuantity: values.outputQuantity.trim(), - outputUom: values.outputUom.trim(), - }); - if (result) navigate('recipe-list'); - }; - - const handleFieldSubmit = (field: Field) => (_value: string) => { - const idx = FIELDS.indexOf(field); - if (idx < FIELDS.length - 1) { - setActiveField(FIELDS[idx + 1] ?? field); - } else { - void handleSubmit(); - } - }; - if (loading) { return ( @@ -115,6 +155,7 @@ export function RecipeCreateScreen() { } const typeLabel = RECIPE_TYPE_LABELS[values.type as RecipeType] ?? values.type; + const uomLabel = UOM_LABELS[UOM_VALUES[uomIdx] as UoM] ?? UOM_VALUES[uomIdx]; return ( @@ -127,18 +168,43 @@ export function RecipeCreateScreen() { return ( - {FIELD_LABELS[field]}: {typeLabel} + {FIELD_LABELS[field]}: {activeField === field ? `< ${typeLabel} >` : typeLabel} {fieldErrors[field] && {fieldErrors[field]}} ); } + if (field === 'outputUom') { + return ( + + + {FIELD_LABELS[field]}: {activeField === field ? `< ${uomLabel} >` : uomLabel} + + + ); + } + if (field === 'articleId') { + return ( + + + {fieldErrors[field] && {fieldErrors[field]}} + + ); + } + const tf = field as TextFields; return ( - Tab/↑↓ Feld wechseln · ←→ Rezepttyp · Enter auf letztem Feld speichern · Escape Abbrechen + Tab/↑↓ Feld wechseln · ←→ Rezepttyp/Einheit · Artikel: Suche tippen, Enter auswählen, nochmal Enter speichern · Escape Abbrechen diff --git a/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx b/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx index b80b6e6..0066f2a 100644 --- a/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx +++ b/frontend/apps/cli/src/components/production/RecipeDetailScreen.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text, useInput } from 'ink'; -import type { RecipeDTO, RecipeType, ProductionStepDTO, IngredientDTO } from '@effigenix/api-client'; +import type { RecipeDTO, RecipeType, RecipeSummaryDTO, ProductionStepDTO, IngredientDTO, ArticleDTO } from '@effigenix/api-client'; import { RECIPE_TYPE_LABELS } from '@effigenix/api-client'; import { useNavigation } from '../../state/navigation-context.js'; import { LoadingSpinner } from '../shared/LoadingSpinner.js'; @@ -9,8 +9,8 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js'; import { ConfirmDialog } from '../shared/ConfirmDialog.js'; import { client } from '../../utils/api-client.js'; -type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back'; -type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive'; +type MenuAction = 'add-ingredient' | 'remove-ingredient' | 'reorder-ingredients' | 'add-step' | 'remove-step' | 'activate' | 'archive' | 'back'; +type Mode = 'menu' | 'select-step-to-remove' | 'confirm-remove' | 'select-ingredient-to-remove' | 'confirm-remove-ingredient' | 'confirm-activate' | 'confirm-archive' | 'reorder-ingredient'; function errorMessage(err: unknown): string { return err instanceof Error ? err.message : 'Unbekannter Fehler'; @@ -31,6 +31,13 @@ export function RecipeDetailScreen() { const [stepToRemove, setStepToRemove] = useState(null); const [selectedIngredientIndex, setSelectedIngredientIndex] = useState(0); const [ingredientToRemove, setIngredientToRemove] = useState(null); + const [articleMap, setArticleMap] = useState>({}); + const [recipeMap, setRecipeMap] = useState>({}); + + // Reorder state + const [reorderList, setReorderList] = useState([]); + const [reorderCursor, setReorderCursor] = useState(0); + const [reorderHeld, setReorderHeld] = useState(null); const isDraft = recipe?.status === 'DRAFT'; const isActive = recipe?.status === 'ACTIVE'; @@ -39,6 +46,9 @@ export function RecipeDetailScreen() { ...(isDraft ? [ { id: 'add-ingredient' as const, label: '[Zutat hinzufügen]' }, { id: 'remove-ingredient' as const, label: '[Zutat entfernen]' }, + ...(sortedIngredientsOf(recipe).length >= 2 + ? [{ id: 'reorder-ingredients' as const, label: '[Zutaten neu anordnen]' }] + : []), { id: 'add-step' as const, label: '[Schritt hinzufügen]' }, { id: 'remove-step' as const, label: '[Schritt entfernen]' }, { id: 'activate' as const, label: '[Rezept aktivieren]' }, @@ -55,16 +65,25 @@ export function RecipeDetailScreen() { ? [...recipe.productionSteps].sort((a, b) => a.stepNumber - b.stepNumber) : []; - const sortedIngredients = recipe?.ingredients - ? [...recipe.ingredients].sort((a, b) => a.position - b.position) - : []; + const sortedIngredients = sortedIngredientsOf(recipe); const loadRecipe = useCallback(() => { setLoading(true); setError(null); - client.recipes.getById(recipeId) - .then((r) => { setRecipe(r); setLoading(false); }) - .catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); }); + Promise.all([ + client.recipes.getById(recipeId), + client.articles.list().catch(() => [] as ArticleDTO[]), + client.recipes.list().catch(() => [] as RecipeSummaryDTO[]), + ]).then(([r, articles, recipes]) => { + setRecipe(r); + const aMap: Record = {}; + for (const a of articles) aMap[a.id] = `${a.name} (${a.articleNumber})`; + setArticleMap(aMap); + const rMap: Record = {}; + for (const rec of recipes) rMap[rec.id] = `${rec.name} v${rec.version}`; + setRecipeMap(rMap); + setLoading(false); + }).catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); }); }, [recipeId]); useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]); @@ -98,6 +117,62 @@ export function RecipeDetailScreen() { } if (key.escape) { setMode('menu'); setSelectedIngredientIndex(0); } } + + if (mode === 'reorder-ingredient') { + if (key.escape) { + setMode('menu'); + setReorderHeld(null); + return; + } + if (key.return) { + void handleReorderSave(); + return; + } + if (_input === ' ') { + if (reorderHeld === null) { + setReorderHeld(reorderCursor); + } else { + setReorderHeld(null); + } + return; + } + if (key.upArrow) { + if (reorderHeld !== null) { + if (reorderCursor > 0) { + setReorderList((list) => { + const newList = [...list]; + const temp = newList[reorderCursor]!; + newList[reorderCursor] = newList[reorderCursor - 1]!; + newList[reorderCursor - 1] = temp; + return newList; + }); + setReorderHeld(reorderCursor - 1); + setReorderCursor((c) => c - 1); + } + } else { + setReorderCursor((c) => Math.max(0, c - 1)); + } + return; + } + if (key.downArrow) { + if (reorderHeld !== null) { + if (reorderCursor < reorderList.length - 1) { + setReorderList((list) => { + const newList = [...list]; + const temp = newList[reorderCursor]!; + newList[reorderCursor] = newList[reorderCursor + 1]!; + newList[reorderCursor + 1] = temp; + return newList; + }); + setReorderHeld(reorderCursor + 1); + setReorderCursor((c) => c + 1); + } + } else { + setReorderCursor((c) => Math.min(reorderList.length - 1, c + 1)); + } + return; + } + } }); const handleAction = async () => { @@ -117,6 +192,12 @@ export function RecipeDetailScreen() { setSelectedIngredientIndex(0); setMode('select-ingredient-to-remove'); break; + case 'reorder-ingredients': + setReorderList([...sortedIngredients]); + setReorderCursor(0); + setReorderHeld(null); + setMode('reorder-ingredient'); + break; case 'add-step': navigate('recipe-add-production-step', { recipeId }); break; @@ -140,6 +221,51 @@ export function RecipeDetailScreen() { } }; + const handleReorderSave = useCallback(async () => { + if (!recipe) return; + + const hasChanged = reorderList.some((ing, idx) => ing.id !== sortedIngredients[idx]?.id); + if (!hasChanged) { + setMode('menu'); + setReorderHeld(null); + return; + } + + setActionLoading(true); + setMode('menu'); + setReorderHeld(null); + + try { + // Remove all ingredients + for (const ing of sortedIngredients) { + await client.recipes.removeIngredient(recipe.id, ing.id); + } + // Re-add in new order + for (let i = 0; i < reorderList.length; i++) { + const ing = reorderList[i]!; + await client.recipes.addIngredient(recipe.id, { + articleId: ing.articleId, + quantity: ing.quantity, + uom: ing.uom, + position: i + 1, + ...(ing.subRecipeId ? { subRecipeId: ing.subRecipeId } : {}), + substitutable: ing.substitutable, + }); + } + const updated = await client.recipes.getById(recipe.id); + setRecipe(updated); + setSuccessMessage('Zutaten-Reihenfolge aktualisiert.'); + } catch (err: unknown) { + setError(errorMessage(err)); + // Reload to get consistent state + try { + const updated = await client.recipes.getById(recipe.id); + setRecipe(updated); + } catch { /* ignore reload error */ } + } + setActionLoading(false); + }, [recipe, reorderList, sortedIngredients]); + const handleRemoveStep = useCallback(async () => { if (!recipe || !stepToRemove) return; setMode('menu'); @@ -246,15 +372,20 @@ export function RecipeDetailScreen() { Ausgabemenge: {recipe.outputQuantity} {recipe.outputUom} + + Artikel: + {articleMap[recipe.articleId] ?? recipe.articleId} + {recipe.ingredients.length > 0 && ( Zutaten: - {recipe.ingredients.map((ing) => ( + {sortedIngredients.map((ing) => ( {ing.position}. {ing.quantity} {ing.uom} - (Artikel: {ing.articleId}) + – {articleMap[ing.articleId] ?? ing.articleId} + {ing.subRecipeId && [Rezept: {recipeMap[ing.subRecipeId] ?? ing.subRecipeId}]} {ing.substitutable && [austauschbar]} ))} @@ -276,6 +407,25 @@ export function RecipeDetailScreen() { )} + {mode === 'reorder-ingredient' && ( + + Zutaten neu anordnen: + {reorderList.map((ing, index) => { + const isCursor = index === reorderCursor; + const isHeld = index === reorderHeld; + const prefix = isHeld ? '[*] ' : isCursor ? ' ▶ ' : ' '; + return ( + + + {prefix}{index + 1}. {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId} + + + ); + })} + ↑↓ bewegen · Space greifen/loslassen · Enter speichern · Escape abbrechen + + )} + {mode === 'select-step-to-remove' && ( Schritt zum Entfernen auswählen: @@ -296,7 +446,7 @@ export function RecipeDetailScreen() { {sortedIngredients.map((ing, index) => ( - {index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} (Artikel: {ing.articleId}) + {index === selectedIngredientIndex ? '▶ ' : ' '}Position {ing.position}: {ing.quantity} {ing.uom} – {articleMap[ing.articleId] ?? ing.articleId} ))} @@ -306,7 +456,7 @@ export function RecipeDetailScreen() { {mode === 'confirm-remove-ingredient' && ingredientToRemove && ( void handleRemoveIngredient()} onCancel={() => { setMode('menu'); setIngredientToRemove(null); }} /> @@ -356,3 +506,8 @@ export function RecipeDetailScreen() { ); } + +function sortedIngredientsOf(recipe: RecipeDTO | null): IngredientDTO[] { + if (!recipe?.ingredients) return []; + return [...recipe.ingredients].sort((a, b) => a.position - b.position); +} diff --git a/frontend/apps/cli/src/components/shared/ArticlePicker.tsx b/frontend/apps/cli/src/components/shared/ArticlePicker.tsx new file mode 100644 index 0000000..17d1309 --- /dev/null +++ b/frontend/apps/cli/src/components/shared/ArticlePicker.tsx @@ -0,0 +1,113 @@ +import { useState, useMemo } from 'react'; +import { Box, Text, useInput } from 'ink'; +import type { ArticleDTO } from '@effigenix/api-client'; + +interface ArticlePickerProps { + articles: ArticleDTO[]; + query: string; + onQueryChange: (q: string) => void; + onSelect: (article: ArticleDTO) => void; + focus: boolean; + selectedName?: string; + maxVisible?: number; +} + +export function ArticlePicker({ + articles, + query, + onQueryChange, + onSelect, + focus, + selectedName, + maxVisible = 5, +}: ArticlePickerProps) { + const [cursor, setCursor] = useState(0); + + const filtered = useMemo(() => { + if (!query) return []; + const q = query.toLowerCase(); + return articles.filter( + (a) => a.name.toLowerCase().includes(q) || a.articleNumber.toLowerCase().includes(q), + ).slice(0, maxVisible); + }, [articles, query, maxVisible]); + + useInput((input, key) => { + if (!focus) return; + + if (key.upArrow) { + setCursor((c) => Math.max(0, c - 1)); + return; + } + if (key.downArrow) { + setCursor((c) => Math.min(filtered.length - 1, c + 1)); + return; + } + if (key.return && filtered.length > 0) { + const selected = filtered[cursor]; + if (selected) onSelect(selected); + return; + } + if (key.backspace || key.delete) { + onQueryChange(query.slice(0, -1)); + setCursor(0); + return; + } + + if (key.tab || key.escape || key.ctrl || key.meta) return; + + if (input && !key.upArrow && !key.downArrow) { + onQueryChange(query + input); + setCursor(0); + } + }, { isActive: focus }); + + if (!focus && selectedName) { + return ( + + Artikel * + + + ✓ {selectedName} + + + ); + } + + if (focus && selectedName && !query) { + return ( + + Artikel * (tippen zum Ändern) + + + ✓ {selectedName} + + + ); + } + + return ( + + Artikel * (Suche) + + + {query || (focus ? '▌' : '')} + + {focus && filtered.length > 0 && ( + + {filtered.map((a, i) => ( + + + {i === cursor ? '▶ ' : ' '}{a.articleNumber} – {a.name} + + + ))} + + )} + {focus && query && filtered.length === 0 && ( + + Keine Artikel gefunden. + + )} + + ); +} diff --git a/frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx b/frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx index 00a642f..5e3bec0 100644 --- a/frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx +++ b/frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx @@ -41,7 +41,7 @@ export function ChangePasswordScreen() { return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f; }); } - if (key.escape) { + if (key.escape || key.backspace) { back(); } }); diff --git a/frontend/apps/cli/src/components/users/UserCreateScreen.tsx b/frontend/apps/cli/src/components/users/UserCreateScreen.tsx index 33d23c5..2b8206c 100644 --- a/frontend/apps/cli/src/components/users/UserCreateScreen.tsx +++ b/frontend/apps/cli/src/components/users/UserCreateScreen.tsx @@ -11,7 +11,7 @@ type Field = 'username' | 'email' | 'password' | 'roleName'; const FIELDS: Field[] = ['username', 'email', 'password', 'roleName']; export function UserCreateScreen() { - const { navigate, back } = useNavigation(); + const { replace, back } = useNavigation(); const { createUser, loading, error, clearError } = useUsers(); const [username, setUsername] = useState(''); @@ -73,7 +73,7 @@ export function UserCreateScreen() { const user = await createUser(username.trim(), email.trim(), password.trim(), roleName.trim() || undefined); if (user) { - navigate('user-list'); + replace('user-list'); } }; diff --git a/frontend/apps/cli/src/state/navigation-context.tsx b/frontend/apps/cli/src/state/navigation-context.tsx index be17508..d455619 100644 --- a/frontend/apps/cli/src/state/navigation-context.tsx +++ b/frontend/apps/cli/src/state/navigation-context.tsx @@ -51,6 +51,7 @@ interface NavigationState { type NavigationAction = | { type: 'NAVIGATE'; screen: Screen; params?: Record } + | { type: 'REPLACE'; screen: Screen; params?: Record } | { type: 'BACK' }; function navigationReducer(state: NavigationState, action: NavigationAction): NavigationState { @@ -61,6 +62,12 @@ function navigationReducer(state: NavigationState, action: NavigationAction): Na history: [...state.history, state.current], params: action.params ?? {}, }; + case 'REPLACE': + return { + current: action.screen, + history: state.history, + params: action.params ?? {}, + }; case 'BACK': { const history = [...state.history]; const previous = history.pop(); @@ -75,6 +82,7 @@ interface NavigationContextValue { params: Record; canGoBack: boolean; navigate: (screen: Screen, params?: Record) => void; + replace: (screen: Screen, params?: Record) => void; back: () => void; } @@ -97,6 +105,7 @@ export function NavigationProvider({ children, initialScreen = 'login' }: Naviga params: state.params, canGoBack: state.history.length > 0, navigate: (screen, params) => dispatch({ type: 'NAVIGATE', screen, params: params ?? {} }), + replace: (screen, params) => dispatch({ type: 'REPLACE', screen, params: params ?? {} }), back: () => dispatch({ type: 'BACK' }), }; diff --git a/frontend/openapi.json b/frontend/openapi.json index ab43566..bf768b9 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":"Stocks","description":"Stock management endpoints"},{"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"}}}},"200":{"description":"User updated successfully","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"}}}},"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":{"403":{"description":"Missing permission","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"200":{"description":"Role assigned successfully","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":{"get":{"tags":["Recipes"],"operationId":"listRecipes","parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"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/recipes/{id}/archive":{"post":{"tags":["Recipes"],"operationId":"archiveRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/activate":{"post":{"tags":["Recipes"],"operationId":"activateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"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/inventory/stocks":{"post":{"tags":["Stocks"],"operationId":"createStock","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStockRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StockResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/stocks/{stockId}/batches":{"post":{"tags":["Stocks"],"operationId":"addBatch","parameters":[{"name":"stockId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddStockBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StockBatchResponse"}}}}},"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":{"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":{"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/recipes/{id}":{"get":{"tags":["Recipes"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"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":{"maxLength":500,"minLength":0,"type":"string"},"durationMinutes":{"minimum":1,"type":"integer","format":"int32"},"temperatureCelsius":{"maximum":1000,"minimum":-273,"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"}}},"CreateStockRequest":{"required":["articleId","storageLocationId"],"type":"object","properties":{"articleId":{"type":"string"},"storageLocationId":{"type":"string"},"minimumLevelAmount":{"type":"string"},"minimumLevelUnit":{"type":"string"},"minimumShelfLifeDays":{"type":"integer","format":"int32"}}},"MinimumLevelResponse":{"required":["amount","unit"],"type":"object","properties":{"amount":{"type":"number"},"unit":{"type":"string"}},"nullable":true},"StockResponse":{"required":["articleId","id","storageLocationId"],"type":"object","properties":{"id":{"type":"string"},"articleId":{"type":"string"},"storageLocationId":{"type":"string"},"minimumLevel":{"$ref":"#/components/schemas/MinimumLevelResponse"},"minimumShelfLifeDays":{"type":"integer","format":"int32","nullable":true}}},"AddStockBatchRequest":{"required":["batchId","batchType","expiryDate","quantityAmount","quantityUnit"],"type":"object","properties":{"batchId":{"type":"string"},"batchType":{"type":"string"},"quantityAmount":{"type":"string"},"quantityUnit":{"type":"string"},"expiryDate":{"type":"string"}}},"StockBatchResponse":{"type":"object","properties":{"id":{"type":"string"},"batchId":{"type":"string"},"batchType":{"type":"string"},"quantityAmount":{"type":"number"},"quantityUnit":{"type":"string"},"expiryDate":{"type":"string","format":"date"},"status":{"type":"string"},"receivedAt":{"type":"string","format":"date-time"}}},"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"}}},"RecipeSummaryResponse":{"required":["createdAt","description","id","ingredientCount","name","outputQuantity","outputUom","status","stepCount","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"},"ingredientCount":{"type":"integer","format":"int32"},"stepCount":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"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":"Batches","description":"Production batch management endpoints"},{"name":"Stocks","description":"Stock management endpoints"},{"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"}}}},"200":{"description":"User updated successfully","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"}}}},"201":{"description":"User created successfully","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UserDTO"}}}},"409":{"description":"Username or email already exists","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":{"200":{"description":"User unlocked 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/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":{"get":{"tags":["Recipes"],"operationId":"listRecipes","parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RecipeSummaryResponse"}}}}}},"security":[{"Bearer Authentication":[]}]},"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/recipes/{id}/archive":{"post":{"tags":["Recipes"],"operationId":"archiveRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/recipes/{id}/activate":{"post":{"tags":["Recipes"],"operationId":"activateRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/production/batches":{"post":{"tags":["Batches"],"operationId":"planBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/BatchResponse"}}}}},"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/inventory/stocks":{"post":{"tags":["Stocks"],"operationId":"createStock","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateStockRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StockResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/stocks/{stockId}/batches":{"post":{"tags":["Stocks"],"operationId":"addBatch","parameters":[{"name":"stockId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddStockBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/StockBatchResponse"}}}}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/stocks/{stockId}/batches/{batchId}/unblock":{"post":{"tags":["Stocks"],"operationId":"unblockBatch","parameters":[{"name":"stockId","in":"path","required":true,"schema":{"type":"string"}},{"name":"batchId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/stocks/{stockId}/batches/{batchId}/remove":{"post":{"tags":["Stocks"],"operationId":"removeBatch","parameters":[{"name":"stockId","in":"path","required":true,"schema":{"type":"string"}},{"name":"batchId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveStockBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK"}},"security":[{"Bearer Authentication":[]}]}},"/api/inventory/stocks/{stockId}/batches/{batchId}/block":{"post":{"tags":["Stocks"],"operationId":"blockBatch","parameters":[{"name":"stockId","in":"path","required":true,"schema":{"type":"string"}},{"name":"batchId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockStockBatchRequest"}}},"required":true},"responses":{"200":{"description":"OK"}},"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":{"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":{"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/recipes/{id}":{"get":{"tags":["Recipes"],"operationId":"getRecipe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/RecipeResponse"}}}}},"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":["articleId","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"},"articleId":{"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":["articleId","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"},"articleId":{"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":{"maxLength":500,"minLength":0,"type":"string"},"durationMinutes":{"minimum":1,"type":"integer","format":"int32"},"temperatureCelsius":{"maximum":1000,"minimum":-273,"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"}}},"PlanBatchRequest":{"required":["bestBeforeDate","plannedQuantity","plannedQuantityUnit","productionDate","recipeId"],"type":"object","properties":{"recipeId":{"type":"string"},"plannedQuantity":{"type":"string"},"plannedQuantityUnit":{"type":"string"},"productionDate":{"type":"string","format":"date"},"bestBeforeDate":{"type":"string","format":"date"}}},"BatchResponse":{"type":"object","properties":{"id":{"type":"string"},"batchNumber":{"type":"string"},"recipeId":{"type":"string"},"status":{"type":"string"},"plannedQuantity":{"type":"string"},"plannedQuantityUnit":{"type":"string"},"productionDate":{"type":"string","format":"date"},"bestBeforeDate":{"type":"string","format":"date"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"CreateStorageLocationRequest":{"required":["name","storageType"],"type":"object","properties":{"name":{"type":"string"},"storageType":{"type":"string"},"minTemperature":{"type":"string"},"maxTemperature":{"type":"string"}}},"CreateStockRequest":{"required":["articleId","storageLocationId"],"type":"object","properties":{"articleId":{"type":"string"},"storageLocationId":{"type":"string"},"minimumLevelAmount":{"type":"string"},"minimumLevelUnit":{"type":"string"},"minimumShelfLifeDays":{"type":"integer","format":"int32"}}},"MinimumLevelResponse":{"required":["amount","unit"],"type":"object","properties":{"amount":{"type":"number"},"unit":{"type":"string"}},"nullable":true},"StockResponse":{"required":["articleId","id","storageLocationId"],"type":"object","properties":{"id":{"type":"string"},"articleId":{"type":"string"},"storageLocationId":{"type":"string"},"minimumLevel":{"$ref":"#/components/schemas/MinimumLevelResponse"},"minimumShelfLifeDays":{"type":"integer","format":"int32","nullable":true}}},"AddStockBatchRequest":{"required":["batchId","batchType","expiryDate","quantityAmount","quantityUnit"],"type":"object","properties":{"batchId":{"type":"string"},"batchType":{"type":"string"},"quantityAmount":{"type":"string"},"quantityUnit":{"type":"string"},"expiryDate":{"type":"string"}}},"StockBatchResponse":{"type":"object","properties":{"id":{"type":"string"},"batchId":{"type":"string"},"batchType":{"type":"string"},"quantityAmount":{"type":"number"},"quantityUnit":{"type":"string"},"expiryDate":{"type":"string","format":"date"},"status":{"type":"string"},"receivedAt":{"type":"string","format":"date-time"}}},"RemoveStockBatchRequest":{"required":["quantityAmount","quantityUnit"],"type":"object","properties":{"quantityAmount":{"type":"string"},"quantityUnit":{"type":"string"}}},"BlockStockBatchRequest":{"required":["reason"],"type":"object","properties":{"reason":{"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"}}},"RecipeSummaryResponse":{"required":["articleId","createdAt","description","id","ingredientCount","name","outputQuantity","outputUom","status","stepCount","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"},"articleId":{"type":"string"},"status":{"type":"string"},"ingredientCount":{"type":"integer","format":"int32"},"stepCount":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"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/api-client/src/index.ts b/frontend/packages/api-client/src/index.ts index ad2ac58..f1201b4 100644 --- a/frontend/packages/api-client/src/index.ts +++ b/frontend/packages/api-client/src/index.ts @@ -113,8 +113,8 @@ export type { StorageLocationFilter, } from './resources/storage-locations.js'; export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js'; -export type { RecipesResource, RecipeType, RecipeStatus } from './resources/recipes.js'; -export { RECIPE_TYPE_LABELS } from './resources/recipes.js'; +export type { RecipesResource, RecipeType, RecipeStatus, UoM } from './resources/recipes.js'; +export { RECIPE_TYPE_LABELS, UOM_VALUES, UOM_LABELS } from './resources/recipes.js'; export type { StocksResource, BatchType } from './resources/stocks.js'; export { BATCH_TYPE_LABELS } from './resources/stocks.js'; diff --git a/frontend/packages/api-client/src/resources/recipes.ts b/frontend/packages/api-client/src/resources/recipes.ts index d971165..a8c8792 100644 --- a/frontend/packages/api-client/src/resources/recipes.ts +++ b/frontend/packages/api-client/src/resources/recipes.ts @@ -20,6 +20,17 @@ export const RECIPE_TYPE_LABELS: Record = { FINISHED_PRODUCT: 'Fertigprodukt', }; +export type UoM = 'KILOGRAM' | 'GRAM' | 'LITER' | 'MILLILITER' | 'PIECE' | 'METER'; +export const UOM_VALUES: UoM[] = ['KILOGRAM', 'GRAM', 'LITER', 'MILLILITER', 'PIECE', 'METER']; +export const UOM_LABELS: Record = { + KILOGRAM: 'Kilogramm (kg)', + GRAM: 'Gramm (g)', + LITER: 'Liter (L)', + MILLILITER: 'Milliliter (mL)', + PIECE: 'Stück (pc)', + METER: 'Meter (m)', +}; + export type { RecipeDTO, RecipeSummaryDTO, diff --git a/frontend/packages/types/src/generated/api.ts b/frontend/packages/types/src/generated/api.ts index 3fbc6c5..298a30c 100644 --- a/frontend/packages/types/src/generated/api.ts +++ b/frontend/packages/types/src/generated/api.ts @@ -420,6 +420,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/production/batches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["planBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/inventory/storage-locations": { parameters: { query?: never; @@ -468,6 +484,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/inventory/stocks/{stockId}/batches/{batchId}/unblock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["unblockBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/inventory/stocks/{stockId}/batches/{batchId}/remove": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["removeBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/inventory/stocks/{stockId}/batches/{batchId}/block": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["blockBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/customers": { parameters: { query?: never; @@ -1171,6 +1235,7 @@ export interface components { shelfLifeDays?: number; outputQuantity: string; outputUom: string; + articleId: string; }; IngredientResponse: { id: string; @@ -1205,6 +1270,7 @@ export interface components { shelfLifeDays?: number | null; outputQuantity: string; outputUom: string; + articleId: string; status: string; ingredients: components["schemas"]["IngredientResponse"][]; productionSteps: components["schemas"]["ProductionStepResponse"][]; @@ -1231,6 +1297,31 @@ export interface components { subRecipeId?: string; substitutable?: boolean; }; + PlanBatchRequest: { + recipeId: string; + plannedQuantity: string; + plannedQuantityUnit: string; + /** Format: date */ + productionDate: string; + /** Format: date */ + bestBeforeDate: string; + }; + BatchResponse: { + id?: string; + batchNumber?: string; + recipeId?: string; + status?: string; + plannedQuantity?: string; + plannedQuantityUnit?: string; + /** Format: date */ + productionDate?: string; + /** Format: date */ + bestBeforeDate?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; CreateStorageLocationRequest: { name: string; storageType: string; @@ -1276,6 +1367,13 @@ export interface components { /** Format: date-time */ receivedAt?: string; }; + RemoveStockBatchRequest: { + quantityAmount: string; + quantityUnit: string; + }; + BlockStockBatchRequest: { + reason: string; + }; CreateCustomerRequest: { name: string; /** @enum {string} */ @@ -1383,6 +1481,7 @@ export interface components { shelfLifeDays?: number | null; outputQuantity: string; outputUom: string; + articleId: string; status: string; /** Format: int32 */ ingredientCount: number; @@ -2388,6 +2487,30 @@ export interface operations { }; }; }; + planBatch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanBatchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BatchResponse"]; + }; + }; + }; + }; listStorageLocations: { parameters: { query?: { @@ -2485,6 +2608,77 @@ export interface operations { }; }; }; + unblockBatch: { + parameters: { + query?: never; + header?: never; + path: { + stockId: string; + batchId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + removeBatch: { + parameters: { + query?: never; + header?: never; + path: { + stockId: string; + batchId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemoveStockBatchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + blockBatch: { + parameters: { + query?: never; + header?: never; + path: { + stockId: string; + batchId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BlockStockBatchRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listCustomers: { parameters: { query?: {