mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 15:29:34 +01:00
feat(production): Produktionsauftrag anlegen (US-P13, #38)
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.
This commit is contained in:
parent
5020df5d93
commit
ba37ff647b
26 changed files with 1781 additions and 0 deletions
|
|
@ -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<ProductionOrderError, ProductionOrder> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
public enum Priority {
|
||||||
|
LOW,
|
||||||
|
NORMAL,
|
||||||
|
HIGH,
|
||||||
|
URGENT
|
||||||
|
}
|
||||||
|
|
@ -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<ProductionOrderError, ProductionOrder> 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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RepositoryError, Optional<ProductionOrder>> findById(ProductionOrderId id);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<ProductionOrder>> findAll();
|
||||||
|
|
||||||
|
Result<RepositoryError, Void> save(ProductionOrder productionOrder);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
public enum ProductionOrderStatus {
|
||||||
|
PLANNED,
|
||||||
|
IN_PROGRESS,
|
||||||
|
COMPLETED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -4,6 +4,7 @@ import de.effigenix.application.production.ActivateRecipe;
|
||||||
import de.effigenix.application.production.ArchiveRecipe;
|
import de.effigenix.application.production.ArchiveRecipe;
|
||||||
import de.effigenix.application.production.AddProductionStep;
|
import de.effigenix.application.production.AddProductionStep;
|
||||||
import de.effigenix.application.production.AddRecipeIngredient;
|
import de.effigenix.application.production.AddRecipeIngredient;
|
||||||
|
import de.effigenix.application.production.CreateProductionOrder;
|
||||||
import de.effigenix.application.production.CancelBatch;
|
import de.effigenix.application.production.CancelBatch;
|
||||||
import de.effigenix.application.production.CompleteBatch;
|
import de.effigenix.application.production.CompleteBatch;
|
||||||
import de.effigenix.application.production.CreateRecipe;
|
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.application.production.RemoveRecipeIngredient;
|
||||||
import de.effigenix.domain.production.BatchNumberGenerator;
|
import de.effigenix.domain.production.BatchNumberGenerator;
|
||||||
import de.effigenix.domain.production.BatchRepository;
|
import de.effigenix.domain.production.BatchRepository;
|
||||||
|
import de.effigenix.domain.production.ProductionOrderRepository;
|
||||||
import de.effigenix.domain.production.RecipeRepository;
|
import de.effigenix.domain.production.RecipeRepository;
|
||||||
import de.effigenix.shared.security.AuthorizationPort;
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
@ -120,4 +122,11 @@ public class ProductionUseCaseConfiguration {
|
||||||
public CancelBatch cancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
|
public CancelBatch cancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
|
||||||
return new CancelBatch(batchRepository, authorizationPort);
|
return new CancelBatch(batchRepository, authorizationPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository,
|
||||||
|
RecipeRepository recipeRepository,
|
||||||
|
AuthorizationPort authorizationPort) {
|
||||||
|
return new CreateProductionOrder(productionOrderRepository, recipeRepository, authorizationPort);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RepositoryError, Optional<ProductionOrder>> findById(ProductionOrderId id) {
|
||||||
|
try {
|
||||||
|
Optional<ProductionOrder> 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<RepositoryError, List<ProductionOrder>> findAll() {
|
||||||
|
try {
|
||||||
|
List<ProductionOrder> 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<RepositoryError, Void> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ProductionOrderEntity, String> {
|
||||||
|
}
|
||||||
|
|
@ -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<ProductionOrderResponse> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.effigenix.infrastructure.production.web.exception;
|
package de.effigenix.infrastructure.production.web.exception;
|
||||||
|
|
||||||
import de.effigenix.domain.production.BatchError;
|
import de.effigenix.domain.production.BatchError;
|
||||||
|
import de.effigenix.domain.production.ProductionOrderError;
|
||||||
import de.effigenix.domain.production.RecipeError;
|
import de.effigenix.domain.production.RecipeError;
|
||||||
|
|
||||||
public final class ProductionErrorHttpStatusMapper {
|
public final class ProductionErrorHttpStatusMapper {
|
||||||
|
|
@ -47,4 +48,17 @@ public final class ProductionErrorHttpStatusMapper {
|
||||||
case BatchError.RepositoryFailure e -> 500;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import de.effigenix.domain.masterdata.ProductCategoryError;
|
||||||
import de.effigenix.domain.masterdata.CustomerError;
|
import de.effigenix.domain.masterdata.CustomerError;
|
||||||
import de.effigenix.domain.masterdata.SupplierError;
|
import de.effigenix.domain.masterdata.SupplierError;
|
||||||
import de.effigenix.domain.production.BatchError;
|
import de.effigenix.domain.production.BatchError;
|
||||||
|
import de.effigenix.domain.production.ProductionOrderError;
|
||||||
import de.effigenix.domain.production.RecipeError;
|
import de.effigenix.domain.production.RecipeError;
|
||||||
import de.effigenix.domain.usermanagement.UserError;
|
import de.effigenix.domain.usermanagement.UserError;
|
||||||
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
|
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.ProductCategoryController;
|
||||||
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
|
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
|
||||||
import de.effigenix.infrastructure.production.web.controller.BatchController;
|
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.controller.RecipeController;
|
||||||
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
|
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
|
||||||
import de.effigenix.shared.common.RepositoryError;
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
|
|
@ -251,6 +253,29 @@ public class GlobalExceptionHandler {
|
||||||
return ResponseEntity.status(status).body(errorResponse);
|
return ResponseEntity.status(status).body(errorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ProductionOrderController.ProductionOrderDomainErrorException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> 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)
|
@ExceptionHandler(BatchController.BatchDomainErrorException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleBatchDomainError(
|
public ResponseEntity<ErrorResponse> handleBatchDomainError(
|
||||||
BatchController.BatchDomainErrorException ex,
|
BatchController.BatchDomainErrorException ex,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="024-create-production-orders-table" author="effigenix">
|
||||||
|
<createTable tableName="production_orders">
|
||||||
|
<column name="id" type="VARCHAR(36)">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="recipe_id" type="VARCHAR(36)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="PLANNED">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="planned_quantity_amount" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="planned_quantity_unit" type="VARCHAR(10)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="planned_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="priority" type="VARCHAR(10)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="notes" type="VARCHAR(1000)"/>
|
||||||
|
<column name="created_at" type="TIMESTAMP WITH TIME ZONE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="updated_at" type="TIMESTAMP WITH TIME ZONE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint
|
||||||
|
baseTableName="production_orders"
|
||||||
|
baseColumnNames="recipe_id"
|
||||||
|
constraintName="fk_production_order_recipe"
|
||||||
|
referencedTableName="recipes"
|
||||||
|
referencedColumnNames="id"/>
|
||||||
|
|
||||||
|
<createIndex tableName="production_orders" indexName="idx_production_order_recipe_id">
|
||||||
|
<column name="recipe_id"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<createIndex tableName="production_orders" indexName="idx_production_order_planned_date">
|
||||||
|
<column name="planned_date"/>
|
||||||
|
</createIndex>
|
||||||
|
|
||||||
|
<sql>ALTER TABLE production_orders ADD CONSTRAINT chk_production_order_status CHECK (status IN ('PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'));</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
|
||||||
|
|
||||||
|
<changeSet id="025-seed-production-order-permissions" author="effigenix">
|
||||||
|
<comment>Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles</comment>
|
||||||
|
|
||||||
|
<!-- ADMIN: PRODUCTION_ORDER_READ -->
|
||||||
|
<insert tableName="role_permissions">
|
||||||
|
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
|
||||||
|
<column name="permission" value="PRODUCTION_ORDER_READ"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- ADMIN: PRODUCTION_ORDER_WRITE -->
|
||||||
|
<insert tableName="role_permissions">
|
||||||
|
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
|
||||||
|
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_READ -->
|
||||||
|
<insert tableName="role_permissions">
|
||||||
|
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
|
||||||
|
<column name="permission" value="PRODUCTION_ORDER_READ"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_WRITE -->
|
||||||
|
<insert tableName="role_permissions">
|
||||||
|
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
|
||||||
|
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -28,5 +28,7 @@
|
||||||
<include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/>
|
<include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/>
|
||||||
<include file="db/changelog/changes/022-add-cancellation-fields-to-batches.xml"/>
|
<include file="db/changelog/changes/022-add-cancellation-fields-to-batches.xml"/>
|
||||||
<include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/>
|
<include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/>
|
||||||
|
<include file="db/changelog/changes/024-create-production-orders-table.xml"/>
|
||||||
|
<include file="db/changelog/changes/025-seed-production-order-permissions.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue