From bfae3eff731a7deb99b8be61f72e2e6aeb5c616c Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Tue, 24 Feb 2026 22:46:23 +0100 Subject: [PATCH] =?UTF-8?q?feat(production):=20Produktion=20=C3=BCber=20Au?= =?UTF-8?q?ftrag=20starten=20(US-P15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RELEASED ProductionOrder kann mit einer PLANNED Batch verknüpft und in Produktion gestartet werden. Dabei wechselt der Order auf IN_PROGRESS und die Batch auf IN_PRODUCTION. Neuer REST-Endpoint POST /{id}/start, StartOrderProduction Use Case, BatchAlreadyAssigned Error, Liquibase- Migration für batch_id FK auf production_orders. --- backend/docs/mvp/ddd/04-production-bc.md | 2 +- .../production/StartProductionOrder.java | 105 +++++ .../command/StartProductionOrderCommand.java | 4 + .../domain/production/ProductionOrder.java | 27 +- .../production/ProductionOrderError.java | 5 + .../ProductionUseCaseConfiguration.java | 8 + .../entity/ProductionOrderEntity.java | 5 + .../mapper/ProductionOrderMapper.java | 2 + .../controller/ProductionOrderController.java | 27 +- .../web/dto/ProductionOrderResponse.java | 2 + .../web/dto/StartProductionOrderRequest.java | 6 + .../ProductionErrorHttpStatusMapper.java | 1 + .../032-add-batch-id-to-production-orders.xml | 25 ++ .../db/changelog/db.changelog-master.xml | 1 + .../ReleaseProductionOrderTest.java | 2 + .../production/StartProductionOrderTest.java | 377 ++++++++++++++++++ .../production/ProductionOrderTest.java | 125 ++++++ ...ductionOrderControllerIntegrationTest.java | 243 ++++++++++- .../loadtest/scenario/ProductionScenario.java | 35 +- 19 files changed, 985 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/production/StartProductionOrder.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/StartProductionOrderCommand.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/StartProductionOrderRequest.java create mode 100644 backend/src/main/resources/db/changelog/changes/032-add-batch-id-to-production-orders.xml create mode 100644 backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java diff --git a/backend/docs/mvp/ddd/04-production-bc.md b/backend/docs/mvp/ddd/04-production-bc.md index 0199585..a6dca1a 100644 --- a/backend/docs/mvp/ddd/04-production-bc.md +++ b/backend/docs/mvp/ddd/04-production-bc.md @@ -1006,7 +1006,7 @@ TraceBatchBackward → BatchTraceabilityService.traceBackward(batchId) // ProductionOrder Management CreateProductionOrder → ProductionOrder.create(ProductionOrderDraft) ReleaseOrder → order.release() -StartOrderProduction → order.startProduction(batchId) + Batch.plan() +StartProductionOrder → order.startProduction(batchId) + batch.startProduction() CompleteOrder → order.complete() CancelOrder → order.cancel(reason) RescheduleOrder → order.reschedule(newDate) diff --git a/backend/src/main/java/de/effigenix/application/production/StartProductionOrder.java b/backend/src/main/java/de/effigenix/application/production/StartProductionOrder.java new file mode 100644 index 0000000..193df10 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/StartProductionOrder.java @@ -0,0 +1,105 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.StartProductionOrderCommand; +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 StartProductionOrder { + + private final ProductionOrderRepository productionOrderRepository; + private final BatchRepository batchRepository; + private final AuthorizationPort authorizationPort; + + public StartProductionOrder( + ProductionOrderRepository productionOrderRepository, + BatchRepository batchRepository, + AuthorizationPort authorizationPort + ) { + this.productionOrderRepository = productionOrderRepository; + this.batchRepository = batchRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(StartProductionOrderCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to start 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(); + } + } + + // Load batch + var batchId = BatchId.of(cmd.batchId()); + Batch batch; + switch (batchRepository.findById(batchId)) { + 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.ValidationFailure("Batch '" + cmd.batchId() + "' not found")); + } + batch = opt.get(); + } + } + + // Batch must be PLANNED + if (batch.status() != BatchStatus.PLANNED) { + return Result.failure(new ProductionOrderError.ValidationFailure( + "Batch '" + cmd.batchId() + "' is not in PLANNED status (current: " + batch.status() + ")")); + } + + // Batch must reference the same recipe as the order + if (!batch.recipeId().equals(order.recipeId())) { + return Result.failure(new ProductionOrderError.ValidationFailure( + "Batch recipe '" + batch.recipeId().value() + "' does not match order recipe '" + order.recipeId().value() + "'")); + } + + // Start production on order (RELEASED → IN_PROGRESS, assigns batchId) + switch (order.startProduction(batchId)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // Start production on batch (PLANNED → IN_PRODUCTION) + switch (batch.startProduction()) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.ValidationFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + // Persist both + switch (productionOrderRepository.save(order)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + + switch (batchRepository.save(batch)) { + 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/StartProductionOrderCommand.java b/backend/src/main/java/de/effigenix/application/production/command/StartProductionOrderCommand.java new file mode 100644 index 0000000..6fd3e52 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/StartProductionOrderCommand.java @@ -0,0 +1,4 @@ +package de.effigenix.application.production.command; + +public record StartProductionOrderCommand(String productionOrderId, String batchId) { +} 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 2fdaaa6..54c8853 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java @@ -19,15 +19,16 @@ import java.time.ZoneOffset; * 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: Further transitions (RELEASED → IN_PROGRESS → COMPLETED, RELEASED/IN_PROGRESS → CANCELLED) - * must be guarded by explicit transition methods once those use cases are implemented. + * 7. Only RELEASED → IN_PROGRESS transition allowed via startProduction(BatchId) + * 8. BatchId is set exactly once (null → non-null) during startProduction() + * 9. BatchId must not already be assigned (BatchAlreadyAssigned) */ public class ProductionOrder { private final ProductionOrderId id; private final RecipeId recipeId; private ProductionOrderStatus status; + private BatchId batchId; private final Quantity plannedQuantity; private final LocalDate plannedDate; private final Priority priority; @@ -40,6 +41,7 @@ public class ProductionOrder { ProductionOrderId id, RecipeId recipeId, ProductionOrderStatus status, + BatchId batchId, Quantity plannedQuantity, LocalDate plannedDate, Priority priority, @@ -51,6 +53,7 @@ public class ProductionOrder { this.id = id; this.recipeId = recipeId; this.status = status; + this.batchId = batchId; this.plannedQuantity = plannedQuantity; this.plannedDate = plannedDate; this.priority = priority; @@ -110,6 +113,7 @@ public class ProductionOrder { ProductionOrderId.generate(), RecipeId.of(draft.recipeId()), ProductionOrderStatus.PLANNED, + null, plannedQuantity, draft.plannedDate(), priority, @@ -124,6 +128,7 @@ public class ProductionOrder { ProductionOrderId id, RecipeId recipeId, ProductionOrderStatus status, + BatchId batchId, Quantity plannedQuantity, LocalDate plannedDate, Priority priority, @@ -132,13 +137,14 @@ public class ProductionOrder { OffsetDateTime updatedAt, long version ) { - return new ProductionOrder(id, recipeId, status, plannedQuantity, plannedDate, + return new ProductionOrder(id, recipeId, status, batchId, plannedQuantity, plannedDate, priority, notes, createdAt, updatedAt, version); } public ProductionOrderId id() { return id; } public RecipeId recipeId() { return recipeId; } public ProductionOrderStatus status() { return status; } + public BatchId batchId() { return batchId; } public Quantity plannedQuantity() { return plannedQuantity; } public LocalDate plannedDate() { return plannedDate; } public Priority priority() { return priority; } @@ -155,4 +161,17 @@ public class ProductionOrder { this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC); return Result.success(null); } + + public Result startProduction(BatchId batchId) { + if (status != ProductionOrderStatus.RELEASED) { + return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, ProductionOrderStatus.IN_PROGRESS)); + } + if (this.batchId != null) { + return Result.failure(new ProductionOrderError.BatchAlreadyAssigned(this.batchId)); + } + this.batchId = batchId; + this.status = ProductionOrderStatus.IN_PROGRESS; + 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 2c5a264..8c375f1 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java @@ -40,6 +40,11 @@ public sealed interface ProductionOrderError { @Override public String message() { return "Cannot transition from " + current + " to " + target; } } + record BatchAlreadyAssigned(BatchId batchId) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_BATCH_ALREADY_ASSIGNED"; } + @Override public String message() { return "Production order already has batch '" + batchId.value() + "' assigned"; } + } + record ValidationFailure(String message) implements ProductionOrderError { @Override public String code() { return "PRODUCTION_ORDER_VALIDATION_ERROR"; } } 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 5c8e55f..93fd73e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -6,6 +6,7 @@ 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.StartProductionOrder; import de.effigenix.application.production.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; @@ -137,4 +138,11 @@ public class ProductionUseCaseConfiguration { AuthorizationPort authorizationPort) { return new ReleaseProductionOrder(productionOrderRepository, recipeRepository, authorizationPort); } + + @Bean + public StartProductionOrder startProductionOrder(ProductionOrderRepository productionOrderRepository, + BatchRepository batchRepository, + AuthorizationPort authorizationPort) { + return new StartProductionOrder(productionOrderRepository, batchRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java index 8aca663..bec6ec6 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java @@ -36,6 +36,9 @@ public class ProductionOrderEntity { @Column(name = "priority", nullable = false, length = 10) private String priority; + @Column(name = "batch_id", length = 36) + private String batchId; + @Column(name = "notes", length = 1000) private String notes; @@ -79,11 +82,13 @@ public class ProductionOrderEntity { public String getPlannedQuantityUnit() { return plannedQuantityUnit; } public LocalDate getPlannedDate() { return plannedDate; } public String getPriority() { return priority; } + public String getBatchId() { return batchId; } public String getNotes() { return notes; } public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; } public void setStatus(String status) { this.status = status; } + public void setBatchId(String batchId) { this.batchId = batchId; } public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } public void setNotes(String notes) { this.notes = notes; } public void setPriority(String priority) { this.priority = priority; } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java index c3906aa..2622fc7 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java @@ -26,6 +26,7 @@ public class ProductionOrderMapper { public void updateEntity(ProductionOrderEntity entity, ProductionOrder order) { entity.setStatus(order.status().name()); + entity.setBatchId(order.batchId() != null ? order.batchId().value() : null); entity.setPriority(order.priority().name()); entity.setNotes(order.notes()); entity.setUpdatedAt(order.updatedAt()); @@ -36,6 +37,7 @@ public class ProductionOrderMapper { ProductionOrderId.of(entity.getId()), RecipeId.of(entity.getRecipeId()), ProductionOrderStatus.valueOf(entity.getStatus()), + entity.getBatchId() != null ? BatchId.of(entity.getBatchId()) : null, Quantity.reconstitute( entity.getPlannedQuantityAmount(), UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()) 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 a95b3df..fdd1915 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 @@ -2,11 +2,14 @@ package de.effigenix.infrastructure.production.web.controller; import de.effigenix.application.production.CreateProductionOrder; import de.effigenix.application.production.ReleaseProductionOrder; +import de.effigenix.application.production.StartProductionOrder; import de.effigenix.application.production.command.CreateProductionOrderCommand; import de.effigenix.application.production.command.ReleaseProductionOrderCommand; +import de.effigenix.application.production.command.StartProductionOrderCommand; import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest; import de.effigenix.infrastructure.production.web.dto.ProductionOrderResponse; +import de.effigenix.infrastructure.production.web.dto.StartProductionOrderRequest; import de.effigenix.shared.security.ActorId; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -29,11 +32,14 @@ public class ProductionOrderController { private final CreateProductionOrder createProductionOrder; private final ReleaseProductionOrder releaseProductionOrder; + private final StartProductionOrder startProductionOrder; public ProductionOrderController(CreateProductionOrder createProductionOrder, - ReleaseProductionOrder releaseProductionOrder) { + ReleaseProductionOrder releaseProductionOrder, + StartProductionOrder startProductionOrder) { this.createProductionOrder = createProductionOrder; this.releaseProductionOrder = releaseProductionOrder; + this.startProductionOrder = startProductionOrder; } @PostMapping @@ -81,6 +87,25 @@ public class ProductionOrderController { return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/start") + @PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')") + public ResponseEntity startProductionOrder( + @PathVariable String id, + @Valid @RequestBody StartProductionOrderRequest request, + Authentication authentication + ) { + logger.info("Starting production for order: {} with batch: {} by actor: {}", id, request.batchId(), authentication.getName()); + + var cmd = new StartProductionOrderCommand(id, request.batchId()); + var result = startProductionOrder.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/dto/ProductionOrderResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java index da1ab90..86cf609 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java @@ -9,6 +9,7 @@ public record ProductionOrderResponse( String id, String recipeId, String status, + String batchId, String plannedQuantity, String plannedQuantityUnit, LocalDate plannedDate, @@ -22,6 +23,7 @@ public record ProductionOrderResponse( order.id().value(), order.recipeId().value(), order.status().name(), + order.batchId() != null ? order.batchId().value() : null, order.plannedQuantity().amount().toPlainString(), order.plannedQuantity().uom().name(), order.plannedDate(), diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/StartProductionOrderRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/StartProductionOrderRequest.java new file mode 100644 index 0000000..5b4b9eb --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/StartProductionOrderRequest.java @@ -0,0 +1,6 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record StartProductionOrderRequest(@NotBlank String batchId) { +} 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 ae767dc..fdca5fc 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 @@ -58,6 +58,7 @@ public final class ProductionErrorHttpStatusMapper { case ProductionOrderError.RecipeNotFound e -> 404; case ProductionOrderError.RecipeNotActive e -> 409; case ProductionOrderError.InvalidStatusTransition e -> 409; + case ProductionOrderError.BatchAlreadyAssigned e -> 409; case ProductionOrderError.ValidationFailure e -> 400; case ProductionOrderError.Unauthorized e -> 403; case ProductionOrderError.RepositoryFailure e -> 500; diff --git a/backend/src/main/resources/db/changelog/changes/032-add-batch-id-to-production-orders.xml b/backend/src/main/resources/db/changelog/changes/032-add-batch-id-to-production-orders.xml new file mode 100644 index 0000000..ac2573f --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/032-add-batch-id-to-production-orders.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + 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 06ae537..d8543f4 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -36,5 +36,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java index dcf77a4..7c45fe9 100644 --- a/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ReleaseProductionOrderTest.java @@ -54,6 +54,7 @@ class ReleaseProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-1"), ProductionOrderStatus.PLANNED, + null, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), PLANNED_DATE, Priority.NORMAL, @@ -69,6 +70,7 @@ class ReleaseProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-1"), ProductionOrderStatus.RELEASED, + null, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), PLANNED_DATE, Priority.NORMAL, diff --git a/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java new file mode 100644 index 0000000..210dde6 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/StartProductionOrderTest.java @@ -0,0 +1,377 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.StartProductionOrderCommand; +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("StartProductionOrder Use Case") +class StartProductionOrderTest { + + @Mock private ProductionOrderRepository productionOrderRepository; + @Mock private BatchRepository batchRepository; + @Mock private AuthorizationPort authPort; + + private StartProductionOrder startProductionOrder; + private ActorId performedBy; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + + @BeforeEach + void setUp() { + startProductionOrder = new StartProductionOrder(productionOrderRepository, batchRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private StartProductionOrderCommand validCommand() { + return new StartProductionOrderCommand("order-1", "batch-1"); + } + + private ProductionOrder releasedOrder() { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + ProductionOrderStatus.RELEASED, + null, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + private ProductionOrder plannedOrder() { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-1"), + ProductionOrderStatus.PLANNED, + null, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + PLANNED_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + } + + private Batch plannedBatch() { + return Batch.reconstitute( + BatchId.of("batch-1"), + new BatchNumber("P-2026-02-24-001"), + RecipeId.of("recipe-1"), + BatchStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, + PLANNED_DATE, PLANNED_DATE.plusDays(14), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, null, null, + 1L, + List.of() + ); + } + + private Batch plannedBatchWithDifferentRecipe() { + return Batch.reconstitute( + BatchId.of("batch-1"), + new BatchNumber("P-2026-02-24-001"), + RecipeId.of("recipe-other"), + BatchStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, + PLANNED_DATE, PLANNED_DATE.plusDays(14), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, null, null, + 1L, + List.of() + ); + } + + private Batch inProductionBatch() { + return Batch.reconstitute( + BatchId.of("batch-1"), + new BatchNumber("P-2026-02-24-001"), + RecipeId.of("recipe-1"), + BatchStatus.IN_PRODUCTION, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, + PLANNED_DATE, PLANNED_DATE.plusDays(14), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, null, null, + 1L, + List.of() + ); + } + + @Test + @DisplayName("should start production when order is RELEASED and batch is PLANNED") + void should_StartProduction_When_ValidCommand() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(plannedBatch()))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + var order = result.unsafeGetValue(); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + assertThat(order.batchId()).isEqualTo(BatchId.of("batch-1")); + verify(productionOrderRepository).save(any(ProductionOrder.class)); + verify(batchRepository).save(any(Batch.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 = startProductionOrder.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 = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ProductionOrderNotFound.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch not found") + void should_Fail_When_BatchNotFound() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch is not PLANNED") + void should_Fail_When_BatchNotPlanned() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(inProductionBatch()))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch recipe does not match order recipe") + void should_Fail_When_RecipeMismatch() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(plannedBatchWithDifferentRecipe()))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + assertThat(result.unsafeGetError().message()).contains("does not match order recipe"); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order is not RELEASED") + void should_Fail_When_OrderNotReleased() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(plannedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(plannedBatch()))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidStatusTransition.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order repository 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 = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch repository findById returns error") + void should_Fail_When_BatchRepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when order save fails") + void should_Fail_When_OrderSaveFails() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(plannedBatch()))); + when(productionOrderRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full"))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when batch save fails") + void should_Fail_When_BatchSaveFails() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(plannedBatch()))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + when(batchRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full"))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when batch is COMPLETED") + void should_Fail_When_BatchCompleted() { + var completedBatch = Batch.reconstitute( + BatchId.of("batch-1"), + new BatchNumber("P-2026-02-24-001"), + RecipeId.of("recipe-1"), + BatchStatus.COMPLETED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + Quantity.of(new BigDecimal("95"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + "done", + PLANNED_DATE, PLANNED_DATE.plusDays(14), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, null, + 1L, + List.of() + ); + + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(completedBatch))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when batch is CANCELLED") + void should_Fail_When_BatchCancelled() { + var cancelledBatch = Batch.reconstitute( + BatchId.of("batch-1"), + new BatchNumber("P-2026-02-24-001"), + RecipeId.of("recipe-1"), + BatchStatus.CANCELLED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, + PLANNED_DATE, PLANNED_DATE.plusDays(14), + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + null, + "Storniert", OffsetDateTime.now(ZoneOffset.UTC), + 1L, + List.of() + ); + + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(productionOrderRepository.findById(ProductionOrderId.of("order-1"))) + .thenReturn(Result.success(Optional.of(releasedOrder()))); + when(batchRepository.findById(BatchId.of("batch-1"))) + .thenReturn(Result.success(Optional.of(cancelledBatch))); + + var result = startProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + verify(productionOrderRepository, never()).save(any()); + } +} 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 b8cfc98..9863cd2 100644 --- a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java @@ -305,6 +305,7 @@ class ProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-123"), status, + null, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), FUTURE_DATE, Priority.NORMAL, @@ -379,6 +380,129 @@ class ProductionOrderTest { } } + @Nested + @DisplayName("startProduction()") + class StartProduction { + + private ProductionOrder orderWithStatus(ProductionOrderStatus status) { + return ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-123"), + status, + null, + 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 + ); + } + + private ProductionOrder releasedOrder() { + return orderWithStatus(ProductionOrderStatus.RELEASED); + } + + @Test + @DisplayName("should start production for RELEASED order") + void should_StartProduction_When_Released() { + var order = releasedOrder(); + var beforeUpdate = order.updatedAt(); + var batchId = BatchId.of("batch-1"); + + var result = order.startProduction(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + assertThat(order.batchId()).isEqualTo(batchId); + assertThat(order.updatedAt()).isAfter(beforeUpdate); + } + + @Test + @DisplayName("should fail when order is PLANNED") + void should_Fail_When_Planned() { + var order = orderWithStatus(ProductionOrderStatus.PLANNED); + + var result = order.startProduction(BatchId.of("batch-1")); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.PLANNED); + assertThat(err.target()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + } + + @Test + @DisplayName("should fail when order is IN_PROGRESS") + void should_Fail_When_InProgress() { + var order = orderWithStatus(ProductionOrderStatus.IN_PROGRESS); + + var result = order.startProduction(BatchId.of("batch-1")); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + assertThat(err.target()).isEqualTo(ProductionOrderStatus.IN_PROGRESS); + } + + @Test + @DisplayName("should fail when order is COMPLETED") + void should_Fail_When_Completed() { + var order = orderWithStatus(ProductionOrderStatus.COMPLETED); + + var result = order.startProduction(BatchId.of("batch-1")); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.COMPLETED); + } + + @Test + @DisplayName("should fail when order is CANCELLED") + void should_Fail_When_Cancelled() { + var order = orderWithStatus(ProductionOrderStatus.CANCELLED); + + var result = order.startProduction(BatchId.of("batch-1")); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(ProductionOrderStatus.CANCELLED); + } + + @Test + @DisplayName("should fail when batchId already assigned") + void should_Fail_When_BatchAlreadyAssigned() { + var order = ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-123"), + ProductionOrderStatus.RELEASED, + BatchId.of("existing-batch"), + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + FUTURE_DATE, + Priority.NORMAL, + null, + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 1L + ); + + var result = order.startProduction(BatchId.of("new-batch")); + + assertThat(result.isFailure()).isTrue(); + var err = (ProductionOrderError.BatchAlreadyAssigned) result.unsafeGetError(); + assertThat(err.batchId().value()).isEqualTo("existing-batch"); + } + + @Test + @DisplayName("batchId should be null after create") + void should_HaveNullBatchId_After_Create() { + var result = ProductionOrder.create(validDraft()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().batchId()).isNull(); + } + } + @Nested @DisplayName("reconstitute()") class Reconstitute { @@ -390,6 +514,7 @@ class ProductionOrderTest { ProductionOrderId.of("order-1"), RecipeId.of("recipe-123"), ProductionOrderStatus.PLANNED, + null, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), FUTURE_DATE, Priority.HIGH, 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 8fd500c..ff8467d 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 @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.production.web; import de.effigenix.domain.usermanagement.RoleName; import de.effigenix.infrastructure.AbstractIntegrationTest; import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest; +import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity; import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; import org.junit.jupiter.api.BeforeEach; @@ -35,7 +36,7 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { UserEntity viewer = createUser("po.viewer", "po.viewer@test.com", Set.of(viewerRole), "BRANCH-01"); adminToken = generateToken(admin.getId(), "po.admin", - "PRODUCTION_ORDER_WRITE,PRODUCTION_ORDER_READ,RECIPE_WRITE,RECIPE_READ"); + "PRODUCTION_ORDER_WRITE,PRODUCTION_ORDER_READ,RECIPE_WRITE,RECIPE_READ,BATCH_WRITE,BATCH_READ"); viewerToken = generateToken(viewer.getId(), "po.viewer", "USER_READ"); } @@ -382,9 +383,203 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { } } + @Nested + @DisplayName("POST /api/production/production-orders/{id}/start – Produktion starten") + class StartProductionOrderEndpoint { + + @Test + @DisplayName("RELEASED Order mit PLANNED Batch starten → 200, Status IN_PROGRESS") + void startOrder_releasedWithPlannedBatch_returns200() throws Exception { + String[] orderAndRecipe = createReleasedOrderWithRecipe(); + String orderId = orderAndRecipe[0]; + String batchId = createPlannedBatch(orderAndRecipe[1]); + + String json = """ + {"batchId": "%s"} + """.formatted(batchId); + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId)) + .andExpect(jsonPath("$.status").value("IN_PROGRESS")) + .andExpect(jsonPath("$.batchId").value(batchId)); + } + + @Test + @DisplayName("PLANNED Order starten → 409 (InvalidStatusTransition)") + void startOrder_plannedOrder_returns409() throws Exception { + String[] orderAndRecipe = createPlannedOrderWithRecipe(); + String orderId = orderAndRecipe[0]; + String batchId = createPlannedBatch(orderAndRecipe[1]); + + String json = """ + {"batchId": "%s"} + """.formatted(batchId); + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Order nicht gefunden → 404") + void startOrder_notFound_returns404() throws Exception { + String json = """ + {"batchId": "non-existent-batch"} + """; + + mockMvc.perform(post("/api/production/production-orders/{id}/start", "non-existent-id") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_NOT_FOUND")); + } + + @Test + @DisplayName("Batch nicht gefunden → 400") + void startOrder_batchNotFound_returns400() throws Exception { + String orderId = createReleasedOrder(); + + String json = """ + {"batchId": "non-existent-batch"} + """; + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403") + void startOrder_withViewerToken_returns403() throws Exception { + String json = """ + {"batchId": "any-batch"} + """; + + mockMvc.perform(post("/api/production/production-orders/{id}/start", "any-id") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void startOrder_withoutToken_returns401() throws Exception { + String json = """ + {"batchId": "any-batch"} + """; + + mockMvc.perform(post("/api/production/production-orders/{id}/start", "any-id") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Batch nicht PLANNED (bereits gestartet) → 400") + void startOrder_batchNotPlanned_returns400() throws Exception { + String[] orderAndRecipe = createReleasedOrderWithRecipe(); + String orderId = orderAndRecipe[0]; + String batchId = createStartedBatch(orderAndRecipe[1]); + + String json = """ + {"batchId": "%s"} + """.formatted(batchId); + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Bereits gestartete Order erneut starten → 409") + void startOrder_alreadyStarted_returns409() throws Exception { + String[] orderAndRecipe = createReleasedOrderWithRecipe(); + String orderId = orderAndRecipe[0]; + String recipeId = orderAndRecipe[1]; + String batchId1 = createPlannedBatch(recipeId); + String batchId2 = createPlannedBatch(recipeId); + + String json1 = """ + {"batchId": "%s"} + """.formatted(batchId1); + + // First start + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json1)) + .andExpect(status().isOk()); + + // Second start + String json2 = """ + {"batchId": "%s"} + """.formatted(batchId2); + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json2)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Batch mit anderem Rezept → 400 (RecipeMismatch)") + void startOrder_recipeMismatch_returns400() throws Exception { + String orderId = createReleasedOrder(); + String batchId = createPlannedBatch(); // creates batch with different recipe + + String json = """ + {"batchId": "%s"} + """.formatted(batchId); + + mockMvc.perform(post("/api/production/production-orders/{id}/start", orderId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR")) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("does not match order recipe"))); + } + + @Test + @DisplayName("batchId leer → 400 (Bean Validation)") + void startOrder_blankBatchId_returns400() throws Exception { + String json = """ + {"batchId": ""} + """; + + mockMvc.perform(post("/api/production/production-orders/{id}/start", "any-id") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + } + // ==================== Hilfsmethoden ==================== private String createPlannedOrder() throws Exception { + return createPlannedOrderWithRecipe()[0]; + } + + /** Returns [orderId, recipeId] */ + private String[] createPlannedOrderWithRecipe() throws Exception { String recipeId = createActiveRecipe(); var request = new CreateProductionOrderRequest( recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); @@ -396,7 +591,8 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(status().isCreated()) .andReturn(); - return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + String orderId = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + return new String[]{orderId, recipeId}; } private String createOrderWithArchivedRecipe() throws Exception { @@ -444,6 +640,49 @@ class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { return recipeId; } + private String createReleasedOrder() throws Exception { + return createReleasedOrderWithRecipe()[0]; + } + + /** Returns [orderId, recipeId] */ + private String[] createReleasedOrderWithRecipe() throws Exception { + String[] orderAndRecipe = createPlannedOrderWithRecipe(); + + mockMvc.perform(post("/api/production/production-orders/{id}/release", orderAndRecipe[0]) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return orderAndRecipe; + } + + private String createPlannedBatch() throws Exception { + return createPlannedBatch(createActiveRecipe()); + } + + private String createPlannedBatch(String recipeId) throws Exception { + var planRequest = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, PLANNED_DATE.plusDays(14)); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + } + + private String createStartedBatch() throws Exception { + return createStartedBatch(createActiveRecipe()); + } + + private String createStartedBatch(String recipeId) throws Exception { + String batchId = createPlannedBatch(recipeId); + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + return batchId; + } + private String createDraftRecipe() throws Exception { String json = """ { diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java index 60a06bc..a38037a 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/ProductionScenario.java @@ -116,10 +116,21 @@ public final class ProductionScenario { ); } + public static ChainBuilder startOrderProduction() { + return exec( + http("Produktionsauftrag starten") + .post("/api/production/production-orders/#{productionOrderId}/start") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body(StringBody("{\"batchId\":\"#{orderBatchId}\"}")) + .check(status().in(200, 400, 409, 500)) + ); + } + // ---- Zusammengesetztes Szenario ---- /** - * Produktions-Workflow: Rezepte lesen → Charge planen → starten → abschließen. + * Produktions-Workflow: Rezepte lesen → Charge planen → Auftrag anlegen → freigeben → mit Charge starten. */ public static ScenarioBuilder productionWorkflow() { return scenario("Produktions-Workflow") @@ -128,7 +139,20 @@ public final class ProductionScenario { .pause(1, 2) .exec(getRandomRecipe()) .pause(1, 2) - // Charge planen und durchlaufen (nur wenn planBatch erfolgreich) + // Charge planen für Order-Start (separate Batch, wird nicht direkt gestartet) + .exec(planBatch()) + .pause(1, 2) + // Produktionsauftrag anlegen, freigeben und mit Charge starten + .exec(createProductionOrder()) + .pause(1, 2) + .doIf(session -> session.contains("productionOrderId") && session.contains("batchId")).then( + exec(releaseProductionOrder()) + .pause(1, 2) + .exec(session -> session.set("orderBatchId", session.getString("batchId"))) + .exec(startOrderProduction()) + ) + .pause(1, 2) + // Separate Charge planen → starten → abschließen (unabhängiger Workflow) .exec(planBatch()) .pause(1, 2) .doIf(session -> session.contains("batchId")).then( @@ -137,13 +161,6 @@ public final class ProductionScenario { .exec(completeBatch()) ) .pause(1, 2) - // Produktionsauftrag anlegen und freigeben - .exec(createProductionOrder()) - .pause(1, 2) - .doIf(session -> session.contains("productionOrderId")).then( - exec(releaseProductionOrder()) - ) - .pause(1, 2) // Nochmal Chargen-Liste prüfen .exec(listBatches()); }