diff --git a/backend/src/main/java/de/effigenix/application/production/ReleaseProductionOrder.java b/backend/src/main/java/de/effigenix/application/production/ReleaseProductionOrder.java new file mode 100644 index 0000000..ea67738 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/ReleaseProductionOrder.java @@ -0,0 +1,81 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.ReleaseProductionOrderCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class ReleaseProductionOrder { + + private final ProductionOrderRepository productionOrderRepository; + private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; + + public ReleaseProductionOrder( + ProductionOrderRepository productionOrderRepository, + RecipeRepository recipeRepository, + AuthorizationPort authorizationPort + ) { + this.productionOrderRepository = productionOrderRepository; + this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(ReleaseProductionOrderCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to release production orders")); + } + + // Load production order + var orderId = ProductionOrderId.of(cmd.productionOrderId()); + ProductionOrder order; + switch (productionOrderRepository.findById(orderId)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new ProductionOrderError.ProductionOrderNotFound(orderId)); + } + order = opt.get(); + } + } + + // Verify recipe is still ACTIVE + Recipe recipe; + switch (recipeRepository.findById(order.recipeId())) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new ProductionOrderError.RecipeNotFound(order.recipeId())); + } + recipe = opt.get(); + } + } + + if (recipe.status() != RecipeStatus.ACTIVE) { + return Result.failure(new ProductionOrderError.RecipeNotActive(recipe.id())); + } + + // Release + switch (order.release()) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // Persist + switch (productionOrderRepository.save(order)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + return Result.success(order); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/ReleaseProductionOrderCommand.java b/backend/src/main/java/de/effigenix/application/production/command/ReleaseProductionOrderCommand.java new file mode 100644 index 0000000..48091aa --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/ReleaseProductionOrderCommand.java @@ -0,0 +1,3 @@ +package de.effigenix.application.production.command; + +public record ReleaseProductionOrderCommand(String productionOrderId) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java index c007ee1..2fdaaa6 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java @@ -18,10 +18,10 @@ import java.time.ZoneOffset; * 3. Status starts as PLANNED * 4. RecipeId must be set (not blank) * 5. Priority must be valid (LOW, NORMAL, HIGH, URGENT) + * 6. Only PLANNED → RELEASED transition allowed via release() * - * TODO: Status transitions (PLANNED → IN_PROGRESS → COMPLETED, PLANNED/IN_PROGRESS → CANCELLED) - * must be guarded by explicit transition methods with invariant checks once those - * use cases are implemented. Do NOT allow arbitrary setStatus(). + * TODO: Further transitions (RELEASED → IN_PROGRESS → COMPLETED, RELEASED/IN_PROGRESS → CANCELLED) + * must be guarded by explicit transition methods once those use cases are implemented. */ public class ProductionOrder { @@ -146,4 +146,13 @@ public class ProductionOrder { public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime updatedAt() { return updatedAt; } public long version() { return version; } + + public Result release() { + if (status != ProductionOrderStatus.PLANNED) { + return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, ProductionOrderStatus.RELEASED)); + } + this.status = ProductionOrderStatus.RELEASED; + this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC); + return Result.success(null); + } } diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java index 9278667..2c5a264 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java @@ -30,6 +30,16 @@ public sealed interface ProductionOrderError { @Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; } } + record RecipeNotFound(RecipeId recipeId) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_RECIPE_NOT_FOUND"; } + @Override public String message() { return "Recipe '" + recipeId.value() + "' not found"; } + } + + record InvalidStatusTransition(ProductionOrderStatus current, ProductionOrderStatus target) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_INVALID_STATUS_TRANSITION"; } + @Override public String message() { return "Cannot transition from " + current + " to " + target; } + } + record ValidationFailure(String message) implements ProductionOrderError { @Override public String code() { return "PRODUCTION_ORDER_VALIDATION_ERROR"; } } diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java index f3db86a..37d1119 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java @@ -2,6 +2,7 @@ package de.effigenix.domain.production; public enum ProductionOrderStatus { PLANNED, + RELEASED, IN_PROGRESS, COMPLETED, CANCELLED diff --git a/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderReleased.java b/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderReleased.java new file mode 100644 index 0000000..6917522 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderReleased.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.production.event; + +import de.effigenix.domain.production.ProductionOrderId; +import de.effigenix.domain.production.RecipeId; + +import java.time.OffsetDateTime; + +public record ProductionOrderReleased( + ProductionOrderId id, + RecipeId recipeId, + OffsetDateTime releasedAt +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index 1128dc4..5c8e55f 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -5,6 +5,7 @@ import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.CreateProductionOrder; +import de.effigenix.application.production.ReleaseProductionOrder; import de.effigenix.application.production.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; @@ -129,4 +130,11 @@ public class ProductionUseCaseConfiguration { AuthorizationPort authorizationPort) { return new CreateProductionOrder(productionOrderRepository, recipeRepository, authorizationPort); } + + @Bean + public ReleaseProductionOrder releaseProductionOrder(ProductionOrderRepository productionOrderRepository, + RecipeRepository recipeRepository, + AuthorizationPort authorizationPort) { + return new ReleaseProductionOrder(productionOrderRepository, recipeRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java index 77909b2..a95b3df 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java @@ -1,7 +1,9 @@ package de.effigenix.infrastructure.production.web.controller; import de.effigenix.application.production.CreateProductionOrder; +import de.effigenix.application.production.ReleaseProductionOrder; import de.effigenix.application.production.command.CreateProductionOrderCommand; +import de.effigenix.application.production.command.ReleaseProductionOrderCommand; import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest; import de.effigenix.infrastructure.production.web.dto.ProductionOrderResponse; @@ -26,9 +28,12 @@ public class ProductionOrderController { private static final Logger logger = LoggerFactory.getLogger(ProductionOrderController.class); private final CreateProductionOrder createProductionOrder; + private final ReleaseProductionOrder releaseProductionOrder; - public ProductionOrderController(CreateProductionOrder createProductionOrder) { + public ProductionOrderController(CreateProductionOrder createProductionOrder, + ReleaseProductionOrder releaseProductionOrder) { this.createProductionOrder = createProductionOrder; + this.releaseProductionOrder = releaseProductionOrder; } @PostMapping @@ -58,6 +63,24 @@ public class ProductionOrderController { .body(ProductionOrderResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/release") + @PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')") + public ResponseEntity releaseProductionOrder( + @PathVariable String id, + Authentication authentication + ) { + logger.info("Releasing production order: {} by actor: {}", id, authentication.getName()); + + var cmd = new ReleaseProductionOrderCommand(id); + var result = releaseProductionOrder.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new ProductionOrderDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue())); + } + public static class ProductionOrderDomainErrorException extends RuntimeException { private final ProductionOrderError error; diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index baf89ea..ae767dc 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -55,7 +55,9 @@ public final class ProductionErrorHttpStatusMapper { case ProductionOrderError.InvalidPlannedQuantity e -> 400; case ProductionOrderError.PlannedDateInPast e -> 400; case ProductionOrderError.InvalidPriority e -> 400; + case ProductionOrderError.RecipeNotFound e -> 404; case ProductionOrderError.RecipeNotActive e -> 409; + case ProductionOrderError.InvalidStatusTransition e -> 409; case ProductionOrderError.ValidationFailure e -> 400; case ProductionOrderError.Unauthorized e -> 403; case ProductionOrderError.RepositoryFailure e -> 500; diff --git a/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java new file mode 100644 index 0000000..dcf77a4 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java @@ -0,0 +1,242 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.ReleaseProductionOrderCommand; +import de.effigenix.domain.production.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ReleaseProductionOrder Use Case") +class ReleaseProductionOrderTest { + + @Mock private ProductionOrderRepository productionOrderRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private AuthorizationPort authPort; + + private ReleaseProductionOrder releaseProductionOrder; + private ActorId performedBy; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + + @BeforeEach + void setUp() { + releaseProductionOrder = new ReleaseProductionOrder(productionOrderRepository, recipeRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private ReleaseProductionOrderCommand validCommand() { + return new ReleaseProductionOrderCommand("order-1"); + } + + private ProductionOrder plannedOrder() { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + ProductionOrderStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + private ProductionOrder releasedOrder() { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + ProductionOrderStatus.RELEASED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + private Recipe activeRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + "article-123", RecipeStatus.ACTIVE, List.of(), List.of(), + OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + private Recipe archivedRecipe() { + return Recipe.reconstitute( + RecipeId.of("recipe-1"), new RecipeName("Bratwurst"), 1, RecipeType.FINISHED_PRODUCT, + null, new YieldPercentage(85), 14, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + "article-123", RecipeStatus.ARCHIVED, List.of(), List.of(), + OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + @Test + @DisplayName("should release PLANNED order when recipe is ACTIVE") + void should_Release_When_ValidCommand() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(ProductionOrderStatus.RELEASED); + verify(productionOrderRepository).save(any(ProductionOrder.class)); + } + + @Test + @DisplayName("should fail when actor lacks PRODUCTION_ORDER_WRITE permission") + void should_Fail_When_Unauthorized() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(false); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.Unauthorized.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when production order not found") + void should_Fail_When_OrderNotFound() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ProductionOrderNotFound.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe is not ACTIVE (ARCHIVED)") + void should_Fail_When_RecipeNotActive() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(archivedRecipe()))); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RecipeNotActive.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe not found") + void should_Fail_When_RecipeNotFound() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RecipeNotFound.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order is already RELEASED (invalid status transition)") + void should_Fail_When_AlreadyReleased() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidStatusTransition.class); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.RELEASED); + assertThat(err.target()).isEqualTo(ProductionOrderStatus.RELEASED); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order repository findById returns error") + void should_Fail_When_OrderRepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe repository returns error") + void should_Fail_When_RecipeRepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when save fails") + void should_Fail_When_SaveFails() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(productionOrderRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full"))); + + var result = releaseProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java index 576a5b4..b8cfc98 100644 --- a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java @@ -296,6 +296,89 @@ class ProductionOrderTest { } } + @Nested + @DisplayName("release()") + class Release { + + private ProductionOrder orderWithStatus(ProductionOrderStatus status) { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-123"), + status, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + FUTURE_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), + OffsetDateTime.now(ZoneOffset.UTC).minusHours(1), + 1L + ); + } + + @Test + @DisplayName("should release PLANNED order") + void should_Release_When_Planned() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + var beforeUpdate = order.updatedAt(); + + var result = order.release(); + + assertThat(result.isSuccess()).isTrue(); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.RELEASED); + assertThat(order.updatedAt()).isAfter(beforeUpdate); + } + + @Test + @DisplayName("should fail when releasing RELEASED order") + void should_Fail_When_AlreadyReleased() { + var order = orderWithStatus(ProductionOrderStatus.RELEASED); + + var result = order.release(); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.RELEASED); + assertThat(err.target()).isEqualTo(ProductionOrderStatus.RELEASED); + } + + @Test + @DisplayName("should fail when releasing IN_PROGRESS order") + void should_Fail_When_InProgress() { + var order = orderWithStatus(ProductionOrderStatus.IN_PROGRESS); + + var result = order.release(); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + assertThat(err.target()).isEqualTo(ProductionOrderStatus.RELEASED); + } + + @Test + @DisplayName("should fail when releasing COMPLETED order") + void should_Fail_When_Completed() { + var order = orderWithStatus(ProductionOrderStatus.COMPLETED); + + var result = order.release(); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.COMPLETED); + } + + @Test + @DisplayName("should fail when releasing CANCELLED order") + void should_Fail_When_Cancelled() { + var order = orderWithStatus(ProductionOrderStatus.CANCELLED); + + var result = order.release(); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.CANCELLED); + } + } + @Nested @DisplayName("reconstitute()") class Reconstitute { diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java index cb3d8ba..8fd500c 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java @@ -313,8 +313,115 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { } } + @Nested + @DisplayName("POST /api/production/production-orders/{id}/release – Produktionsauftrag freigeben") + class ReleaseProductionOrderEndpoint { + + @Test + @DisplayName("PLANNED Order freigeben → 200, Status RELEASED") + void releaseOrder_planned_returns200() throws Exception { + String orderId = createPlannedOrder(); + + mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId)) + .andExpect(jsonPath("$.status").value("RELEASED")); + } + + @Test + @DisplayName("Bereits RELEASED Order erneut freigeben → 409") + void releaseOrder_alreadyReleased_returns409() throws Exception { + String orderId = createPlannedOrder(); + + // First release + mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + // Second release + mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Order nicht gefunden → 404") + void releaseOrder_notFound_returns404() throws Exception { + mockMvc.perform(post("/api/production/production-orders/{id}/release", "non-existent-id") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND")); + } + + @Test + @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403") + void releaseOrder_withViewerToken_returns403() throws Exception { + mockMvc.perform(post("/api/production/production-orders/{id}/release", "any-id") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void releaseOrder_withoutToken_returns401() throws Exception { + mockMvc.perform(post("/api/production/production-orders/{id}/release", "any-id")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Order mit archiviertem Rezept freigeben → 409") + void releaseOrder_archivedRecipe_returns409() throws Exception { + String orderId = createOrderWithArchivedRecipe(); + + mockMvc.perform(post("/api/production/production-orders/{id}/release", orderId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RECIPE_NOT_ACTIVE")); + } + } + // ==================== Hilfsmethoden ==================== + private String createPlannedOrder() throws Exception { + String recipeId = createActiveRecipe(); + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + var result = mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String createOrderWithArchivedRecipe() throws Exception { + // Create active recipe, create order, then archive the recipe + String recipeId = createActiveRecipe(); + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + var result = mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + String orderId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + + // Archive the recipe + mockMvc.perform(post("/api/recipes/{id}/archive", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return orderId; + } + private String createActiveRecipe() throws Exception { String recipeId = createDraftRecipe();