From ba37ff647bde775660dc7d93ddc928ecc67284a8 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Mon, 23 Feb 2026 23:14:20 +0100 Subject: [PATCH] feat(production): Produktionsauftrag anlegen (US-P13, #38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductionOrder-Aggregate mit DDD + Clean Architecture eingeführt. Produktionsleiter können Aufträge mit Rezept, Menge, Termin und Priorität planen. Validierung: Quantity > 0, PlannedDate nicht in Vergangenheit, Priority (LOW/NORMAL/HIGH/URGENT), Recipe ACTIVE. --- .../production/CreateProductionOrder.java | 77 ++++ .../command/CreateProductionOrderCommand.java | 12 + .../effigenix/domain/production/Priority.java | 8 + .../domain/production/ProductionOrder.java | 149 +++++++ .../production/ProductionOrderDraft.java | 12 + .../production/ProductionOrderError.java | 44 +++ .../domain/production/ProductionOrderId.java | 20 + .../production/ProductionOrderRepository.java | 16 + .../production/ProductionOrderStatus.java | 8 + .../event/ProductionOrderCreated.java | 12 + .../ProductionUseCaseConfiguration.java | 9 + .../entity/ProductionOrderEntity.java | 90 +++++ .../mapper/ProductionOrderMapper.java | 51 +++ .../JpaProductionOrderRepository.java | 79 ++++ .../ProductionOrderJpaRepository.java | 7 + .../controller/ProductionOrderController.java | 73 ++++ .../web/dto/CreateProductionOrderRequest.java | 15 + .../web/dto/ProductionOrderResponse.java | 34 ++ .../ProductionErrorHttpStatusMapper.java | 14 + .../web/exception/GlobalExceptionHandler.java | 25 ++ .../024-create-production-orders-table.xml | 61 +++ .../025-seed-production-order-permissions.xml | 36 ++ .../db/changelog/db.changelog-master.xml | 2 + .../production/CreateProductionOrderTest.java | 236 ++++++++++++ .../production/ProductionOrderTest.java | 327 ++++++++++++++++ ...ductionOrderControllerIntegrationTest.java | 364 ++++++++++++++++++ 26 files changed, 1781 insertions(+) create mode 100644 backend/src/main/java/de/effigenix/application/production/CreateProductionOrder.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/CreateProductionOrderCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/Priority.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrderDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrderId.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderCreated.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaProductionOrderRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/ProductionOrderJpaRepository.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateProductionOrderRequest.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java create mode 100644 backend/src/main/resources/db/changelog/changes/024-create-production-orders-table.xml create mode 100644 backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml create mode 100644 backend/src/test/java/de/effigenix/application/production/CreateProductionOrderTest.java create mode 100644 backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java create mode 100644 backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/CreateProductionOrder.java b/backend/src/main/java/de/effigenix/application/production/CreateProductionOrder.java new file mode 100644 index 0000000..721d2ee --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/CreateProductionOrder.java @@ -0,0 +1,77 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.CreateProductionOrderCommand; +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 CreateProductionOrder { + + private final ProductionOrderRepository productionOrderRepository; + private final RecipeRepository recipeRepository; + private final AuthorizationPort authorizationPort; + + public CreateProductionOrder( + ProductionOrderRepository productionOrderRepository, + RecipeRepository recipeRepository, + AuthorizationPort authorizationPort + ) { + this.productionOrderRepository = productionOrderRepository; + this.recipeRepository = recipeRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(CreateProductionOrderCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) { + return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to create production orders")); + } + + // Verify recipe exists and is ACTIVE + Recipe recipe; + switch (recipeRepository.findById(RecipeId.of(cmd.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.ValidationFailure( + "Recipe with ID '" + cmd.recipeId() + "' not found")); + } + recipe = opt.get(); + } + } + + if (recipe.status() != RecipeStatus.ACTIVE) { + return Result.failure(new ProductionOrderError.RecipeNotActive(recipe.id())); + } + + // Create production order + var draft = new ProductionOrderDraft( + cmd.recipeId(), + cmd.plannedQuantity(), + cmd.plannedQuantityUnit(), + cmd.plannedDate(), + cmd.priority(), + cmd.notes() + ); + + ProductionOrder order; + switch (ProductionOrder.create(draft)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> order = val; + } + + // 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/CreateProductionOrderCommand.java b/backend/src/main/java/de/effigenix/application/production/command/CreateProductionOrderCommand.java new file mode 100644 index 0000000..788207a --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/CreateProductionOrderCommand.java @@ -0,0 +1,12 @@ +package de.effigenix.application.production.command; + +import java.time.LocalDate; + +public record CreateProductionOrderCommand( + String recipeId, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate plannedDate, + String priority, + String notes +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/Priority.java b/backend/src/main/java/de/effigenix/domain/production/Priority.java new file mode 100644 index 0000000..4450e38 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/Priority.java @@ -0,0 +1,8 @@ +package de.effigenix.domain.production; + +public enum Priority { + LOW, + NORMAL, + HIGH, + URGENT +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java new file mode 100644 index 0000000..c007ee1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrder.java @@ -0,0 +1,149 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/** + * ProductionOrder aggregate root. + * + * Invariants: + * 1. PlannedQuantity must be positive + * 2. PlannedDate must not be in the past (today is allowed) + * 3. Status starts as PLANNED + * 4. RecipeId must be set (not blank) + * 5. Priority must be valid (LOW, NORMAL, HIGH, URGENT) + * + * 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(). + */ +public class ProductionOrder { + + private final ProductionOrderId id; + private final RecipeId recipeId; + private ProductionOrderStatus status; + private final Quantity plannedQuantity; + private final LocalDate plannedDate; + private final Priority priority; + private final String notes; + private final OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private final long version; + + private ProductionOrder( + ProductionOrderId id, + RecipeId recipeId, + ProductionOrderStatus status, + Quantity plannedQuantity, + LocalDate plannedDate, + Priority priority, + String notes, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + long version + ) { + this.id = id; + this.recipeId = recipeId; + this.status = status; + this.plannedQuantity = plannedQuantity; + this.plannedDate = plannedDate; + this.priority = priority; + this.notes = notes; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static Result create(ProductionOrderDraft draft) { + if (draft.recipeId() == null || draft.recipeId().isBlank()) { + return Result.failure(new ProductionOrderError.ValidationFailure("recipeId must not be blank")); + } + + if (draft.plannedDate() == null) { + return Result.failure(new ProductionOrderError.ValidationFailure("plannedDate must not be null")); + } + + if (draft.plannedDate().isBefore(LocalDate.now())) { + return Result.failure(new ProductionOrderError.PlannedDateInPast(draft.plannedDate())); + } + + Priority priority; + try { + priority = Priority.valueOf(draft.priority()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new ProductionOrderError.InvalidPriority(draft.priority())); + } + + if (draft.plannedQuantity() == null || draft.plannedQuantity().isBlank()) { + return Result.failure(new ProductionOrderError.InvalidPlannedQuantity("plannedQuantity must not be blank")); + } + if (draft.plannedQuantityUnit() == null || draft.plannedQuantityUnit().isBlank()) { + return Result.failure(new ProductionOrderError.InvalidPlannedQuantity("plannedQuantityUnit must not be blank")); + } + + Quantity plannedQuantity; + try { + var amount = new BigDecimal(draft.plannedQuantity()); + var uom = UnitOfMeasure.valueOf(draft.plannedQuantityUnit()); + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> { + return Result.failure(new ProductionOrderError.InvalidPlannedQuantity(err.toString())); + } + case Result.Success(var qty) -> plannedQuantity = qty; + } + } catch (NumberFormatException e) { + return Result.failure(new ProductionOrderError.InvalidPlannedQuantity( + "Invalid amount format: " + draft.plannedQuantity())); + } catch (IllegalArgumentException e) { + return Result.failure(new ProductionOrderError.InvalidPlannedQuantity( + "Invalid unit: " + draft.plannedQuantityUnit())); + } + + var now = OffsetDateTime.now(ZoneOffset.UTC); + return Result.success(new ProductionOrder( + ProductionOrderId.generate(), + RecipeId.of(draft.recipeId()), + ProductionOrderStatus.PLANNED, + plannedQuantity, + draft.plannedDate(), + priority, + draft.notes(), + now, + now, + 0L + )); + } + + public static ProductionOrder reconstitute( + ProductionOrderId id, + RecipeId recipeId, + ProductionOrderStatus status, + Quantity plannedQuantity, + LocalDate plannedDate, + Priority priority, + String notes, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + long version + ) { + return new ProductionOrder(id, recipeId, status, plannedQuantity, plannedDate, + priority, notes, createdAt, updatedAt, version); + } + + public ProductionOrderId id() { return id; } + public RecipeId recipeId() { return recipeId; } + public ProductionOrderStatus status() { return status; } + public Quantity plannedQuantity() { return plannedQuantity; } + public LocalDate plannedDate() { return plannedDate; } + public Priority priority() { return priority; } + public String notes() { return notes; } + public OffsetDateTime createdAt() { return createdAt; } + public OffsetDateTime updatedAt() { return updatedAt; } + public long version() { return version; } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderDraft.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderDraft.java new file mode 100644 index 0000000..4ca81f4 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderDraft.java @@ -0,0 +1,12 @@ +package de.effigenix.domain.production; + +import java.time.LocalDate; + +public record ProductionOrderDraft( + String recipeId, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate plannedDate, + String priority, + String notes +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java new file mode 100644 index 0000000..9278667 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderError.java @@ -0,0 +1,44 @@ +package de.effigenix.domain.production; + +public sealed interface ProductionOrderError { + + String code(); + String message(); + + record ProductionOrderNotFound(ProductionOrderId id) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_NOT_FOUND"; } + @Override public String message() { return "Production order with ID '" + id.value() + "' not found"; } + } + + record InvalidPlannedQuantity(String reason) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_INVALID_PLANNED_QUANTITY"; } + @Override public String message() { return "Invalid planned quantity: " + reason; } + } + + record PlannedDateInPast(java.time.LocalDate date) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_PLANNED_DATE_IN_PAST"; } + @Override public String message() { return "Planned date " + date + " is in the past"; } + } + + record InvalidPriority(String value) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_INVALID_PRIORITY"; } + @Override public String message() { return "Invalid priority: " + value; } + } + + record RecipeNotActive(RecipeId recipeId) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_RECIPE_NOT_ACTIVE"; } + @Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; } + } + + record ValidationFailure(String message) implements ProductionOrderError { + @Override public String code() { return "PRODUCTION_ORDER_VALIDATION_ERROR"; } + } + + record Unauthorized(String message) implements ProductionOrderError { + @Override public String code() { return "UNAUTHORIZED"; } + } + + record RepositoryFailure(String message) implements ProductionOrderError { + @Override public String code() { return "REPOSITORY_ERROR"; } + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderId.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderId.java new file mode 100644 index 0000000..7a3e641 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderId.java @@ -0,0 +1,20 @@ +package de.effigenix.domain.production; + +import java.util.UUID; + +public record ProductionOrderId(String value) { + + public ProductionOrderId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("ProductionOrderId must not be blank"); + } + } + + public static ProductionOrderId generate() { + return new ProductionOrderId(UUID.randomUUID().toString()); + } + + public static ProductionOrderId of(String value) { + return new ProductionOrderId(value); + } +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java new file mode 100644 index 0000000..a930504 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderRepository.java @@ -0,0 +1,16 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; + +import java.util.List; +import java.util.Optional; + +public interface ProductionOrderRepository { + + Result> findById(ProductionOrderId id); + + Result> findAll(); + + Result save(ProductionOrder productionOrder); +} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java new file mode 100644 index 0000000..f3db86a --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionOrderStatus.java @@ -0,0 +1,8 @@ +package de.effigenix.domain.production; + +public enum ProductionOrderStatus { + PLANNED, + IN_PROGRESS, + COMPLETED, + CANCELLED +} diff --git a/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderCreated.java b/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderCreated.java new file mode 100644 index 0000000..1f286a9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/event/ProductionOrderCreated.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 ProductionOrderCreated( + ProductionOrderId id, + RecipeId recipeId, + OffsetDateTime createdAt +) {} 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 3903fd6..1128dc4 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -4,6 +4,7 @@ import de.effigenix.application.production.ActivateRecipe; 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.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; @@ -20,6 +21,7 @@ import de.effigenix.application.production.RemoveProductionStep; import de.effigenix.application.production.RemoveRecipeIngredient; import de.effigenix.domain.production.BatchNumberGenerator; import de.effigenix.domain.production.BatchRepository; +import de.effigenix.domain.production.ProductionOrderRepository; import de.effigenix.domain.production.RecipeRepository; import de.effigenix.shared.security.AuthorizationPort; import org.springframework.context.annotation.Bean; @@ -120,4 +122,11 @@ public class ProductionUseCaseConfiguration { public CancelBatch cancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { return new CancelBatch(batchRepository, authorizationPort); } + + @Bean + public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository, + RecipeRepository recipeRepository, + AuthorizationPort authorizationPort) { + return new CreateProductionOrder(productionOrderRepository, recipeRepository, 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 new file mode 100644 index 0000000..8aca663 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/ProductionOrderEntity.java @@ -0,0 +1,90 @@ +package de.effigenix.infrastructure.production.persistence.entity; + +import jakarta.persistence.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "production_orders") +public class ProductionOrderEntity { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "recipe_id", nullable = false, length = 36) + private String recipeId; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "planned_quantity_amount", nullable = false, precision = 19, scale = 6) + private BigDecimal plannedQuantityAmount; + + @Column(name = "planned_quantity_unit", nullable = false, length = 10) + private String plannedQuantityUnit; + + @Column(name = "planned_date", nullable = false) + private LocalDate plannedDate; + + @Column(name = "priority", nullable = false, length = 10) + private String priority; + + @Column(name = "notes", length = 1000) + private String notes; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + protected ProductionOrderEntity() {} + + public ProductionOrderEntity( + String id, + String recipeId, + String status, + BigDecimal plannedQuantityAmount, + String plannedQuantityUnit, + LocalDate plannedDate, + String priority, + String notes, + OffsetDateTime createdAt, + OffsetDateTime updatedAt + ) { + this.id = id; + this.recipeId = recipeId; + this.status = status; + this.plannedQuantityAmount = plannedQuantityAmount; + this.plannedQuantityUnit = plannedQuantityUnit; + this.plannedDate = plannedDate; + this.priority = priority; + this.notes = notes; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public String getId() { return id; } + public Long getVersion() { return version; } + public String getRecipeId() { return recipeId; } + public String getStatus() { return status; } + public BigDecimal getPlannedQuantityAmount() { return plannedQuantityAmount; } + public String getPlannedQuantityUnit() { return plannedQuantityUnit; } + public LocalDate getPlannedDate() { return plannedDate; } + public String getPriority() { return priority; } + 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 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 new file mode 100644 index 0000000..c3906aa --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/ProductionOrderMapper.java @@ -0,0 +1,51 @@ +package de.effigenix.infrastructure.production.persistence.mapper; + +import de.effigenix.domain.production.*; +import de.effigenix.infrastructure.production.persistence.entity.ProductionOrderEntity; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +import org.springframework.stereotype.Component; + +@Component +public class ProductionOrderMapper { + + public ProductionOrderEntity toEntity(ProductionOrder order) { + return new ProductionOrderEntity( + order.id().value(), + order.recipeId().value(), + order.status().name(), + order.plannedQuantity().amount(), + order.plannedQuantity().uom().name(), + order.plannedDate(), + order.priority().name(), + order.notes(), + order.createdAt(), + order.updatedAt() + ); + } + + public void updateEntity(ProductionOrderEntity entity, ProductionOrder order) { + entity.setStatus(order.status().name()); + entity.setPriority(order.priority().name()); + entity.setNotes(order.notes()); + entity.setUpdatedAt(order.updatedAt()); + } + + public ProductionOrder toDomain(ProductionOrderEntity entity) { + return ProductionOrder.reconstitute( + ProductionOrderId.of(entity.getId()), + RecipeId.of(entity.getRecipeId()), + ProductionOrderStatus.valueOf(entity.getStatus()), + Quantity.reconstitute( + entity.getPlannedQuantityAmount(), + UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()) + ), + entity.getPlannedDate(), + Priority.valueOf(entity.getPriority()), + entity.getNotes(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getVersion() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaProductionOrderRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaProductionOrderRepository.java new file mode 100644 index 0000000..72cf657 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/JpaProductionOrderRepository.java @@ -0,0 +1,79 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.domain.production.ProductionOrder; +import de.effigenix.domain.production.ProductionOrderId; +import de.effigenix.domain.production.ProductionOrderRepository; +import de.effigenix.infrastructure.production.persistence.mapper.ProductionOrderMapper; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +@Profile("!no-db") +@Transactional(readOnly = true) +public class JpaProductionOrderRepository implements ProductionOrderRepository { + + private static final Logger logger = LoggerFactory.getLogger(JpaProductionOrderRepository.class); + + private final ProductionOrderJpaRepository jpaRepository; + private final ProductionOrderMapper mapper; + + public JpaProductionOrderRepository(ProductionOrderJpaRepository jpaRepository, ProductionOrderMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Result> findById(ProductionOrderId id) { + try { + Optional result = jpaRepository.findById(id.value()) + .map(mapper::toDomain); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findById", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAll() { + try { + List result = jpaRepository.findAll().stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.trace("Database error in findAll", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + @Transactional + public Result save(ProductionOrder order) { + try { + var existing = jpaRepository.findById(order.id().value()); + if (existing.isPresent()) { + mapper.updateEntity(existing.get(), order); + } else { + jpaRepository.save(mapper.toEntity(order)); + } + return Result.success(null); + } catch (ObjectOptimisticLockingFailureException e) { + logger.warn("Optimistic locking failure for production order {}", order.id().value()); + return Result.failure(new RepositoryError.ConcurrentModification( + "Production order was modified by another transaction")); + } catch (Exception e) { + logger.trace("Database error in save", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/ProductionOrderJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/ProductionOrderJpaRepository.java new file mode 100644 index 0000000..69f5581 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/repository/ProductionOrderJpaRepository.java @@ -0,0 +1,7 @@ +package de.effigenix.infrastructure.production.persistence.repository; + +import de.effigenix.infrastructure.production.persistence.entity.ProductionOrderEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductionOrderJpaRepository extends JpaRepository { +} 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 new file mode 100644 index 0000000..77909b2 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/ProductionOrderController.java @@ -0,0 +1,73 @@ +package de.effigenix.infrastructure.production.web.controller; + +import de.effigenix.application.production.CreateProductionOrder; +import de.effigenix.application.production.command.CreateProductionOrderCommand; +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.shared.security.ActorId; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/production/production-orders") +@SecurityRequirement(name = "Bearer Authentication") +@Tag(name = "Production Orders", description = "Production order management endpoints") +public class ProductionOrderController { + + private static final Logger logger = LoggerFactory.getLogger(ProductionOrderController.class); + + private final CreateProductionOrder createProductionOrder; + + public ProductionOrderController(CreateProductionOrder createProductionOrder) { + this.createProductionOrder = createProductionOrder; + } + + @PostMapping + @PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')") + public ResponseEntity createProductionOrder( + @Valid @RequestBody CreateProductionOrderRequest request, + Authentication authentication + ) { + logger.info("Creating production order for recipe: {} by actor: {}", request.recipeId(), authentication.getName()); + + var cmd = new CreateProductionOrderCommand( + request.recipeId(), + request.plannedQuantity(), + request.plannedQuantityUnit(), + request.plannedDate(), + request.priority(), + request.notes() + ); + + var result = createProductionOrder.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new ProductionOrderDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ProductionOrderResponse.from(result.unsafeGetValue())); + } + + public static class ProductionOrderDomainErrorException extends RuntimeException { + private final ProductionOrderError error; + + public ProductionOrderDomainErrorException(ProductionOrderError error) { + super(error.message()); + this.error = error; + } + + public ProductionOrderError getError() { + return error; + } + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateProductionOrderRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateProductionOrderRequest.java new file mode 100644 index 0000000..eac9356 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CreateProductionOrderRequest.java @@ -0,0 +1,15 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record CreateProductionOrderRequest( + @NotBlank String recipeId, + @NotBlank String plannedQuantity, + @NotBlank String plannedQuantityUnit, + @NotNull LocalDate plannedDate, + @NotBlank String priority, + String notes +) {} 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 new file mode 100644 index 0000000..da1ab90 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/ProductionOrderResponse.java @@ -0,0 +1,34 @@ +package de.effigenix.infrastructure.production.web.dto; + +import de.effigenix.domain.production.ProductionOrder; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +public record ProductionOrderResponse( + String id, + String recipeId, + String status, + String plannedQuantity, + String plannedQuantityUnit, + LocalDate plannedDate, + String priority, + String notes, + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { + public static ProductionOrderResponse from(ProductionOrder order) { + return new ProductionOrderResponse( + order.id().value(), + order.recipeId().value(), + order.status().name(), + order.plannedQuantity().amount().toPlainString(), + order.plannedQuantity().uom().name(), + order.plannedDate(), + order.priority().name(), + order.notes(), + order.createdAt(), + order.updatedAt() + ); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index 527448d..baf89ea 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 @@ -1,6 +1,7 @@ package de.effigenix.infrastructure.production.web.exception; import de.effigenix.domain.production.BatchError; +import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.domain.production.RecipeError; public final class ProductionErrorHttpStatusMapper { @@ -47,4 +48,17 @@ public final class ProductionErrorHttpStatusMapper { case BatchError.RepositoryFailure e -> 500; }; } + + public static int toHttpStatus(ProductionOrderError error) { + return switch (error) { + case ProductionOrderError.ProductionOrderNotFound e -> 404; + case ProductionOrderError.InvalidPlannedQuantity e -> 400; + case ProductionOrderError.PlannedDateInPast e -> 400; + case ProductionOrderError.InvalidPriority e -> 400; + case ProductionOrderError.RecipeNotActive e -> 409; + case ProductionOrderError.ValidationFailure e -> 400; + case ProductionOrderError.Unauthorized e -> 403; + case ProductionOrderError.RepositoryFailure e -> 500; + }; + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java index 226cb79..e8ccd30 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/effigenix/infrastructure/usermanagement/web/exception/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import de.effigenix.domain.masterdata.ProductCategoryError; import de.effigenix.domain.masterdata.CustomerError; import de.effigenix.domain.masterdata.SupplierError; import de.effigenix.domain.production.BatchError; +import de.effigenix.domain.production.ProductionOrderError; import de.effigenix.domain.production.RecipeError; import de.effigenix.domain.usermanagement.UserError; import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController; @@ -17,6 +18,7 @@ import de.effigenix.infrastructure.masterdata.web.controller.CustomerController; import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController; import de.effigenix.infrastructure.masterdata.web.controller.SupplierController; import de.effigenix.infrastructure.production.web.controller.BatchController; +import de.effigenix.infrastructure.production.web.controller.ProductionOrderController; import de.effigenix.infrastructure.production.web.controller.RecipeController; import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper; import de.effigenix.shared.common.RepositoryError; @@ -251,6 +253,29 @@ public class GlobalExceptionHandler { return ResponseEntity.status(status).body(errorResponse); } + @ExceptionHandler(ProductionOrderController.ProductionOrderDomainErrorException.class) + public ResponseEntity handleProductionOrderDomainError( + ProductionOrderController.ProductionOrderDomainErrorException ex, + HttpServletRequest request + ) { + ProductionOrderError error = ex.getError(); + int status = ProductionErrorHttpStatusMapper.toHttpStatus(error); + logDomainError("ProductionOrder", error.code(), error.message(), status); + + String clientMessage = status >= 500 + ? "An internal error occurred" + : error.message(); + + ErrorResponse errorResponse = ErrorResponse.from( + error.code(), + clientMessage, + status, + request.getRequestURI() + ); + + return ResponseEntity.status(status).body(errorResponse); + } + @ExceptionHandler(BatchController.BatchDomainErrorException.class) public ResponseEntity handleBatchDomainError( BatchController.BatchDomainErrorException ex, diff --git a/backend/src/main/resources/db/changelog/changes/024-create-production-orders-table.xml b/backend/src/main/resources/db/changelog/changes/024-create-production-orders-table.xml new file mode 100644 index 0000000..af541ba --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/024-create-production-orders-table.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE production_orders ADD CONSTRAINT chk_production_order_status CHECK (status IN ('PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED')); + + + diff --git a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml new file mode 100644 index 0000000..f60b9da --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml @@ -0,0 +1,36 @@ + + + + + Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 339254d..fa33129 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -28,5 +28,7 @@ + + diff --git a/backend/src/test/java/de/effigenix/application/production/CreateProductionOrderTest.java b/backend/src/test/java/de/effigenix/application/production/CreateProductionOrderTest.java new file mode 100644 index 0000000..a5e580b --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/CreateProductionOrderTest.java @@ -0,0 +1,236 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.CreateProductionOrderCommand; +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("CreateProductionOrder Use Case") +class CreateProductionOrderTest { + + @Mock private ProductionOrderRepository productionOrderRepository; + @Mock private RecipeRepository recipeRepository; + @Mock private AuthorizationPort authPort; + + private CreateProductionOrder createProductionOrder; + private ActorId performedBy; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + + @BeforeEach + void setUp() { + createProductionOrder = new CreateProductionOrder(productionOrderRepository, recipeRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private CreateProductionOrderCommand validCommand() { + return new CreateProductionOrderCommand("recipe-1", "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + } + + 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 draftRecipe() { + 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.DRAFT, List.of(), List.of(), + OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + @Test + @DisplayName("should create production order when recipe is ACTIVE and all data valid") + void should_CreateOrder_When_ValidCommand() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + + var result = createProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + var order = result.unsafeGetValue(); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.PLANNED); + assertThat(order.recipeId().value()).isEqualTo("recipe-1"); + assertThat(order.priority()).isEqualTo(Priority.NORMAL); + 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 = createProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.Unauthorized.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(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = createProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + assertThat(result.unsafeGetError().message()).contains("recipe-1"); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when recipe is not ACTIVE") + void should_Fail_When_RecipeNotActive() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(draftRecipe()))); + + var result = createProductionOrder.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 repository returns error") + void should_Fail_When_RecipeRepositoryError() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = createProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when production order repository save fails") + void should_Fail_When_SaveFails() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + 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 = createProductionOrder.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when planned quantity is invalid") + void should_Fail_When_InvalidQuantity() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + + var cmd = new CreateProductionOrderCommand("recipe-1", "0", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + var result = createProductionOrder.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when planned date is in the past") + void should_Fail_When_PlannedDateInPast() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + + var cmd = new CreateProductionOrderCommand("recipe-1", "100", "KILOGRAM", + LocalDate.now().minusDays(1), "NORMAL", null); + var result = createProductionOrder.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.PlannedDateInPast.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when priority is invalid") + void should_Fail_When_InvalidPriority() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + + var cmd = new CreateProductionOrderCommand("recipe-1", "100", "KILOGRAM", PLANNED_DATE, "SUPER_HIGH", null); + var result = createProductionOrder.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPriority.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when unit is invalid") + void should_Fail_When_InvalidUnit() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + + var cmd = new CreateProductionOrderCommand("recipe-1", "100", "INVALID", PLANNED_DATE, "NORMAL", null); + var result = createProductionOrder.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + verify(productionOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("should pass notes through to production order") + void should_PassNotes_When_Provided() { + when(authPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)).thenReturn(true); + when(recipeRepository.findById(RecipeId.of("recipe-1"))) + .thenReturn(Result.success(Optional.of(activeRecipe()))); + when(productionOrderRepository.save(any())).thenReturn(Result.success(null)); + + var cmd = new CreateProductionOrderCommand("recipe-1", "100", "KILOGRAM", PLANNED_DATE, "URGENT", "Eilauftrag"); + var result = createProductionOrder.execute(cmd, performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().notes()).isEqualTo("Eilauftrag"); + assertThat(result.unsafeGetValue().priority()).isEqualTo(Priority.URGENT); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java new file mode 100644 index 0000000..576a5b4 --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/production/ProductionOrderTest.java @@ -0,0 +1,327 @@ +package de.effigenix.domain.production; + +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.UnitOfMeasure; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ProductionOrder Aggregate") +class ProductionOrderTest { + + private static final LocalDate FUTURE_DATE = LocalDate.now().plusDays(7); + + private ProductionOrderDraft validDraft() { + return new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + } + + @Nested + @DisplayName("create()") + class Create { + + @Test + @DisplayName("should create production order with valid draft") + void should_CreateOrder_When_ValidDraft() { + var result = ProductionOrder.create(validDraft()); + + assertThat(result.isSuccess()).isTrue(); + var order = result.unsafeGetValue(); + assertThat(order.id()).isNotNull(); + assertThat(order.recipeId().value()).isEqualTo("recipe-123"); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.PLANNED); + assertThat(order.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100")); + assertThat(order.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM); + assertThat(order.plannedDate()).isEqualTo(FUTURE_DATE); + assertThat(order.priority()).isEqualTo(Priority.NORMAL); + assertThat(order.notes()).isNull(); + assertThat(order.createdAt()).isNotNull(); + assertThat(order.updatedAt()).isNotNull(); + } + + @Test + @DisplayName("should accept notes when provided") + void should_AcceptNotes_When_Provided() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "HIGH", "Eilauftrag"); + + var result = ProductionOrder.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().notes()).isEqualTo("Eilauftrag"); + assertThat(result.unsafeGetValue().priority()).isEqualTo(Priority.HIGH); + } + + @Test + @DisplayName("should accept all priority values") + void should_AcceptAllPriorities() { + for (Priority p : Priority.values()) { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, p.name(), null); + var result = ProductionOrder.create(draft); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().priority()).isEqualTo(p); + } + } + + @Test + @DisplayName("should fail when recipeId is blank") + void should_Fail_When_RecipeIdBlank() { + var draft = new ProductionOrderDraft("", "100", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when recipeId is null") + void should_Fail_When_RecipeIdNull() { + var draft = new ProductionOrderDraft(null, "100", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is zero") + void should_Fail_When_QuantityZero() { + var draft = new ProductionOrderDraft("recipe-123", "0", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is negative") + void should_Fail_When_QuantityNegative() { + var draft = new ProductionOrderDraft("recipe-123", "-5", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedQuantity is not a number") + void should_Fail_When_QuantityNotANumber() { + var draft = new ProductionOrderDraft("recipe-123", "abc", "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when unit is invalid") + void should_Fail_When_UnitInvalid() { + var draft = new ProductionOrderDraft("recipe-123", "100", "INVALID_UNIT", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedDate is yesterday") + void should_Fail_When_PlannedDateYesterday() { + var yesterday = LocalDate.now().minusDays(1); + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", yesterday, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.PlannedDateInPast.class); + var err = (ProductionOrderError.PlannedDateInPast) result.unsafeGetError(); + assertThat(err.date()).isEqualTo(yesterday); + } + + @Test + @DisplayName("should succeed when plannedDate is today") + void should_Succeed_When_PlannedDateToday() { + var today = LocalDate.now(); + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", today, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().plannedDate()).isEqualTo(today); + } + + @Test + @DisplayName("should fail when plannedDate is null") + void should_Fail_When_PlannedDateNull() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", null, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.ValidationFailure.class); + } + + @Test + @DisplayName("should fail when priority is invalid") + void should_Fail_When_PriorityInvalid() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "SUPER_HIGH", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPriority.class); + var err = (ProductionOrderError.InvalidPriority) result.unsafeGetError(); + assertThat(err.value()).isEqualTo("SUPER_HIGH"); + } + + @Test + @DisplayName("should fail when priority is null") + void should_Fail_When_PriorityNull() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, null, null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPriority.class); + } + + @Test + @DisplayName("should accept decimal quantity") + void should_Accept_When_DecimalQuantity() { + var draft = new ProductionOrderDraft("recipe-123", "50.75", "LITER", FUTURE_DATE, "LOW", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().plannedQuantity().amount()) + .isEqualByComparingTo(new BigDecimal("50.75")); + assertThat(result.unsafeGetValue().plannedQuantity().uom()).isEqualTo(UnitOfMeasure.LITER); + } + + @Test + @DisplayName("should fail when plannedQuantity is null") + void should_Fail_When_QuantityNull() { + var draft = new ProductionOrderDraft("recipe-123", null, "KILOGRAM", FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedQuantityUnit is null") + void should_Fail_When_UnitNull() { + var draft = new ProductionOrderDraft("recipe-123", "100", null, FUTURE_DATE, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPlannedQuantity.class); + } + + @Test + @DisplayName("should fail when plannedDate is far in the past") + void should_Fail_When_PlannedDateFarInPast() { + var pastDate = LocalDate.of(2020, 1, 1); + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", pastDate, "NORMAL", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.PlannedDateInPast.class); + } + + @Test + @DisplayName("should fail when priority is empty string") + void should_Fail_When_PriorityEmpty() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPriority.class); + } + + @Test + @DisplayName("should fail when priority is lowercase") + void should_Fail_When_PriorityLowercase() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "normal", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(ProductionOrderError.InvalidPriority.class); + } + + @Test + @DisplayName("should set version to 0 on creation") + void should_SetVersionToZero_When_Created() { + var result = ProductionOrder.create(validDraft()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().version()).isEqualTo(0L); + } + + @Test + @DisplayName("should set createdAt equal to updatedAt on creation") + void should_SetTimestampsEqual_When_Created() { + var result = ProductionOrder.create(validDraft()); + + assertThat(result.isSuccess()).isTrue(); + var order = result.unsafeGetValue(); + assertThat(order.createdAt()).isEqualTo(order.updatedAt()); + } + + @Test + @DisplayName("should accept null notes") + void should_Accept_When_NotesNull() { + var draft = new ProductionOrderDraft("recipe-123", "100", "KILOGRAM", FUTURE_DATE, "URGENT", null); + + var result = ProductionOrder.create(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().notes()).isNull(); + assertThat(result.unsafeGetValue().priority()).isEqualTo(Priority.URGENT); + } + } + + @Nested + @DisplayName("reconstitute()") + class Reconstitute { + + @Test + @DisplayName("should reconstitute production order from persistence") + void should_Reconstitute_When_ValidData() { + var order = ProductionOrder.reconstitute( + ProductionOrderId.of("order-1"), + RecipeId.of("recipe-123"), + ProductionOrderStatus.PLANNED, + Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + FUTURE_DATE, + Priority.HIGH, + "Wichtiger Auftrag", + OffsetDateTime.now(ZoneOffset.UTC), + OffsetDateTime.now(ZoneOffset.UTC), + 5L + ); + + assertThat(order.id().value()).isEqualTo("order-1"); + assertThat(order.recipeId().value()).isEqualTo("recipe-123"); + assertThat(order.status()).isEqualTo(ProductionOrderStatus.PLANNED); + assertThat(order.priority()).isEqualTo(Priority.HIGH); + assertThat(order.notes()).isEqualTo("Wichtiger Auftrag"); + assertThat(order.version()).isEqualTo(5L); + } + } +} 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 new file mode 100644 index 0000000..cb3d8ba --- /dev/null +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/ProductionOrderControllerIntegrationTest.java @@ -0,0 +1,364 @@ +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.usermanagement.persistence.entity.RoleEntity; +import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.time.LocalDate; +import java.util.Set; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("ProductionOrder Controller Integration Tests") +class ProductionOrderControllerIntegrationTest extends AbstractIntegrationTest { + + private String adminToken; + private String viewerToken; + + private static final LocalDate PLANNED_DATE = LocalDate.now().plusDays(7); + + @BeforeEach + void setUp() throws Exception { + RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin"); + RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); + + UserEntity admin = createUser("po.admin", "po.admin@test.com", Set.of(adminRole), "BRANCH-01"); + 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"); + viewerToken = generateToken(viewer.getId(), "po.viewer", "USER_READ"); + } + + @Nested + @DisplayName("POST /api/production/production-orders – Produktionsauftrag anlegen") + class CreateProductionOrderEndpoint { + + @Test + @DisplayName("Produktionsauftrag mit gültigen Daten → 201") + void createOrder_withValidData_returns201() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.recipeId").value(recipeId)) + .andExpect(jsonPath("$.status").value("PLANNED")) + .andExpect(jsonPath("$.plannedQuantity").value("100.000000")) + .andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM")) + .andExpect(jsonPath("$.plannedDate").value(PLANNED_DATE.toString())) + .andExpect(jsonPath("$.priority").value("NORMAL")) + .andExpect(jsonPath("$.notes").doesNotExist()) + .andExpect(jsonPath("$.createdAt").isNotEmpty()) + .andExpect(jsonPath("$.updatedAt").isNotEmpty()); + } + + @Test + @DisplayName("Produktionsauftrag mit Notes → 201") + void createOrder_withNotes_returns201() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "50", "LITER", PLANNED_DATE, "URGENT", "Eilauftrag Kunde X"); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.priority").value("URGENT")) + .andExpect(jsonPath("$.notes").value("Eilauftrag Kunde X")); + } + + @Test + @DisplayName("PlannedQuantity 0 → 400") + void createOrder_zeroQuantity_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "0", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("Negative Menge → 400") + void createOrder_negativeQuantity_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "-10", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("PlannedDate in der Vergangenheit → 400") + void createOrder_pastDate_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", LocalDate.now().minusDays(1), "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_PLANNED_DATE_IN_PAST")); + } + + @Test + @DisplayName("Ungültige Priority → 400") + void createOrder_invalidPriority_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, "SUPER_HIGH", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_PRIORITY")); + } + + @Test + @DisplayName("Rezept nicht gefunden → 400") + void createOrder_recipeNotFound_returns400() throws Exception { + var request = new CreateProductionOrderRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_VALIDATION_ERROR")); + } + + @Test + @DisplayName("Rezept nicht ACTIVE (DRAFT) → 409") + void createOrder_recipeNotActive_returns409() throws Exception { + String recipeId = createDraftRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_RECIPE_NOT_ACTIVE")); + } + + @Test + @DisplayName("recipeId leer → 400 (Bean Validation)") + void createOrder_blankRecipeId_returns400() throws Exception { + var request = new CreateProductionOrderRequest( + "", "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Ungültige Unit → 400") + void createOrder_invalidUnit_returns400() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "INVALID_UNIT", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PRODUCTION_ORDER_INVALID_PLANNED_QUANTITY")); + } + + @Test + @DisplayName("PlannedDate heute → 201 (Grenzwert)") + void createOrder_todayDate_returns201() throws Exception { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", LocalDate.now(), "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.plannedDate").value(LocalDate.now().toString())); + } + + @Test + @DisplayName("priority leer → 400 (Bean Validation)") + void createOrder_blankPriority_returns400() throws Exception { + var request = new CreateProductionOrderRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("plannedDate null → 400 (Bean Validation)") + void createOrder_nullDate_returns400() throws Exception { + String json = """ + {"recipeId": "%s", "plannedQuantity": "100", "plannedQuantityUnit": "KILOGRAM", "priority": "NORMAL"} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Alle Priority-Werte → 201") + void createOrder_allPriorities_returns201() throws Exception { + for (String priority : new String[]{"LOW", "NORMAL", "HIGH", "URGENT"}) { + String recipeId = createActiveRecipe(); + + var request = new CreateProductionOrderRequest( + recipeId, "100", "KILOGRAM", PLANNED_DATE, priority, null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.priority").value(priority)); + } + } + + @Test + @DisplayName("plannedQuantity leer → 400 (Bean Validation)") + void createOrder_blankQuantity_returns400() throws Exception { + var request = new CreateProductionOrderRequest( + UUID.randomUUID().toString(), "", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Authorization") + class AuthTests { + + @Test + @DisplayName("Ohne PRODUCTION_ORDER_WRITE → 403") + void createOrder_withViewerToken_returns403() throws Exception { + var request = new CreateProductionOrderRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void createOrder_withoutToken_returns401() throws Exception { + var request = new CreateProductionOrderRequest( + UUID.randomUUID().toString(), "100", "KILOGRAM", PLANNED_DATE, "NORMAL", null); + + mockMvc.perform(post("/api/production/production-orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + // ==================== Hilfsmethoden ==================== + + private String createActiveRecipe() throws Exception { + String recipeId = createDraftRecipe(); + + // Add ingredient (required for activation) + String ingredientJson = """ + {"position": 1, "articleId": "%s", "quantity": "5.5", "uom": "KILOGRAM", "substitutable": false} + """.formatted(UUID.randomUUID().toString()); + + mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(ingredientJson)) + .andExpect(status().isCreated()); + + // Activate + mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + return recipeId; + } + + private String createDraftRecipe() throws Exception { + String json = """ + { + "name": "Test-Rezept-%s", + "version": 1, + "type": "FINISHED_PRODUCT", + "description": "Testrezept", + "yieldPercentage": 85, + "shelfLifeDays": 14, + "outputQuantity": "100", + "outputUom": "KILOGRAM", + "articleId": "article-123" + } + """.formatted(UUID.randomUUID().toString().substring(0, 8)); + + var result = mockMvc.perform(post("/api/recipes") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } +}