1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:19:35 +01:00

feat(production): Recipe Aggregate als Full Vertical Slice (#26)

Domain: Recipe Aggregate Root mit create(RecipeDraft), RecipeId, RecipeName,
RecipeType, RecipeStatus, RecipeError, RecipeRepository Interface.
Application: CreateRecipe Use Case mit Name+Version Uniqueness-Check.
Infrastructure: JPA Entity/Mapper/Repository, REST POST /api/recipes,
Liquibase Migration, ProductionErrorHttpStatusMapper, Spring Config.
Tests: 15 Unit Tests für Recipe Aggregate (75 total im Production BC).
This commit is contained in:
Sebastian Frick 2026-02-19 10:12:04 +01:00
parent 24a6869faf
commit 9b9b7311d1
23 changed files with 1095 additions and 0 deletions

View file

@ -0,0 +1,50 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CreateRecipe {
private final RecipeRepository recipeRepository;
public CreateRecipe(RecipeRepository recipeRepository) {
this.recipeRepository = recipeRepository;
}
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
var draft = new RecipeDraft(
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
cmd.yieldPercentage(), cmd.shelfLifeDays(),
cmd.outputQuantity(), cmd.outputUom()
);
Recipe recipe;
switch (Recipe.create(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> recipe = val;
}
// Uniqueness check: Name + Version
switch (recipeRepository.existsByNameAndVersion(cmd.name(), cmd.version())) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var exists) -> {
if (exists) {
return Result.failure(new RecipeError.NameAndVersionAlreadyExists(cmd.name(), cmd.version()));
}
}
}
switch (recipeRepository.save(recipe)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(recipe);
}
}

View file

@ -0,0 +1,14 @@
package de.effigenix.application.production.command;
import de.effigenix.domain.production.RecipeType;
public record CreateRecipeCommand(
String name,
int version,
RecipeType type,
String description,
int yieldPercentage,
Integer shelfLifeDays,
String outputQuantity,
String outputUom
) {}

View file

@ -0,0 +1,172 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import static de.effigenix.shared.common.Result.*;
/**
* Recipe aggregate root.
*
* Invariants:
* 1. Name must not be blank
* 2. Version >= 1
* 3. YieldPercentage 1-200%
* 4. ShelfLifeDays > 0 for FINISHED_PRODUCT and INTERMEDIATE
* 5. OutputQuantity must be positive
* 6. New recipes always start in DRAFT status
*/
public class Recipe {
private final RecipeId id;
private RecipeName name;
private int version;
private RecipeType type;
private String description;
private YieldPercentage yieldPercentage;
private Integer shelfLifeDays;
private Quantity outputQuantity;
private RecipeStatus status;
private final LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Recipe(
RecipeId id,
RecipeName name,
int version,
RecipeType type,
String description,
YieldPercentage yieldPercentage,
Integer shelfLifeDays,
Quantity outputQuantity,
RecipeStatus status,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.id = id;
this.name = name;
this.version = version;
this.type = type;
this.description = description;
this.yieldPercentage = yieldPercentage;
this.shelfLifeDays = shelfLifeDays;
this.outputQuantity = outputQuantity;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
/**
* Factory: Creates a new Recipe in DRAFT status from raw inputs.
* Orchestrates VO validation internally.
*/
public static Result<RecipeError, Recipe> create(RecipeDraft draft) {
// 1. Name (required)
RecipeName name;
switch (RecipeName.of(draft.name())) {
case Failure(var msg) -> { return Result.failure(new RecipeError.ValidationFailure(msg)); }
case Success(var val) -> name = val;
}
// 2. Version >= 1
if (draft.version() < 1) {
return Result.failure(new RecipeError.ValidationFailure("Version must be >= 1, was: " + draft.version()));
}
// 3. YieldPercentage (1-200)
YieldPercentage yieldPercentage;
switch (YieldPercentage.of(draft.yieldPercentage())) {
case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); }
case Success(var val) -> yieldPercentage = val;
}
// 4. ShelfLifeDays: required and > 0 for FINISHED_PRODUCT and INTERMEDIATE
Integer shelfLifeDays = draft.shelfLifeDays();
if (draft.type() == RecipeType.FINISHED_PRODUCT || draft.type() == RecipeType.INTERMEDIATE) {
if (shelfLifeDays == null || shelfLifeDays <= 0) {
return Result.failure(new RecipeError.InvalidShelfLife(
"ShelfLifeDays must be > 0 for " + draft.type() + ", was: " + shelfLifeDays));
}
}
// 5. Output Quantity (required, positive)
Quantity outputQuantity;
try {
BigDecimal amount = new BigDecimal(draft.outputQuantity());
UnitOfMeasure uom = UnitOfMeasure.valueOf(draft.outputUom());
switch (Quantity.of(amount, uom)) {
case Failure(var err) -> { return Result.failure(new RecipeError.ValidationFailure(err.message())); }
case Success(var val) -> outputQuantity = val;
}
} catch (IllegalArgumentException e) {
return Result.failure(new RecipeError.ValidationFailure("Invalid output quantity: " + e.getMessage()));
}
var now = LocalDateTime.now();
return Result.success(new Recipe(
RecipeId.generate(), name, draft.version(), draft.type(),
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
RecipeStatus.DRAFT, now, now
));
}
/**
* Reconstitutes a Recipe from persistence. No validation.
*/
public static Recipe reconstitute(
RecipeId id,
RecipeName name,
int version,
RecipeType type,
String description,
YieldPercentage yieldPercentage,
Integer shelfLifeDays,
Quantity outputQuantity,
RecipeStatus status,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
return new Recipe(id, name, version, type, description,
yieldPercentage, shelfLifeDays, outputQuantity, status, createdAt, updatedAt);
}
// ==================== Getters ====================
public RecipeId id() { return id; }
public RecipeName name() { return name; }
public int version() { return version; }
public RecipeType type() { return type; }
public String description() { return description; }
public YieldPercentage yieldPercentage() { return yieldPercentage; }
public Integer shelfLifeDays() { return shelfLifeDays; }
public Quantity outputQuantity() { return outputQuantity; }
public RecipeStatus status() { return status; }
public LocalDateTime createdAt() { return createdAt; }
public LocalDateTime updatedAt() { return updatedAt; }
// ==================== Helpers ====================
private void touch() {
this.updatedAt = LocalDateTime.now();
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Recipe other)) return false;
return id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public String toString() {
return "Recipe{id=" + id + ", name=" + name + ", version=" + version + ", status=" + status + "}";
}
}

View file

@ -0,0 +1,25 @@
package de.effigenix.domain.production;
/**
* Draft for creating a new Recipe. Application layer builds this from raw command inputs.
* The Recipe aggregate validates and constructs VOs internally.
*
* @param name Recipe name (required)
* @param version Version number (required, >= 1)
* @param type Recipe type (required)
* @param description Free-text description (optional)
* @param yieldPercentage Yield percentage 1-200 (required)
* @param shelfLifeDays Shelf life in days (nullable; required for FINISHED_PRODUCT and INTERMEDIATE)
* @param outputQuantity Expected output quantity amount (required)
* @param outputUom Expected output unit of measure (required)
*/
public record RecipeDraft(
String name,
int version,
RecipeType type,
String description,
int yieldPercentage,
Integer shelfLifeDays,
String outputQuantity,
String outputUom
) {}

View file

@ -0,0 +1,34 @@
package de.effigenix.domain.production;
public sealed interface RecipeError {
String code();
String message();
record RecipeNotFound(RecipeId id) implements RecipeError {
@Override public String code() { return "RECIPE_NOT_FOUND"; }
@Override public String message() { return "Recipe with ID '" + id.value() + "' not found"; }
}
record NameAndVersionAlreadyExists(String name, int version) implements RecipeError {
@Override public String code() { return "RECIPE_NAME_VERSION_EXISTS"; }
@Override public String message() { return "Recipe '" + name + "' version " + version + " already exists"; }
}
record InvalidShelfLife(String reason) implements RecipeError {
@Override public String code() { return "RECIPE_INVALID_SHELF_LIFE"; }
@Override public String message() { return "Invalid shelf life: " + reason; }
}
record ValidationFailure(String message) implements RecipeError {
@Override public String code() { return "RECIPE_VALIDATION_ERROR"; }
}
record Unauthorized(String message) implements RecipeError {
@Override public String code() { return "UNAUTHORIZED"; }
}
record RepositoryFailure(String message) implements RecipeError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}
}

View file

@ -0,0 +1,20 @@
package de.effigenix.domain.production;
import java.util.UUID;
public record RecipeId(String value) {
public RecipeId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("RecipeId must not be blank");
}
}
public static RecipeId generate() {
return new RecipeId(UUID.randomUUID().toString());
}
public static RecipeId of(String value) {
return new RecipeId(value);
}
}

View file

@ -0,0 +1,23 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
public record RecipeName(String value) {
public RecipeName {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("RecipeName must not be blank");
}
if (value.length() > 200) {
throw new IllegalArgumentException("RecipeName must not exceed 200 characters");
}
}
public static Result<String, RecipeName> of(String value) {
try {
return Result.success(new RecipeName(value));
} catch (IllegalArgumentException e) {
return Result.failure(e.getMessage());
}
}
}

View file

@ -0,0 +1,20 @@
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 RecipeRepository {
Result<RepositoryError, Optional<Recipe>> findById(RecipeId id);
Result<RepositoryError, List<Recipe>> findAll();
Result<RepositoryError, Void> save(Recipe recipe);
Result<RepositoryError, Void> delete(Recipe recipe);
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
}

View file

@ -0,0 +1,7 @@
package de.effigenix.domain.production;
public enum RecipeStatus {
DRAFT,
ACTIVE,
ARCHIVED
}

View file

@ -0,0 +1,7 @@
package de.effigenix.domain.production;
public enum RecipeType {
RAW_MATERIAL,
INTERMEDIATE,
FINISHED_PRODUCT
}

View file

@ -0,0 +1,15 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.domain.production.RecipeRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ProductionUseCaseConfiguration {
@Bean
public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
return new CreateRecipe(recipeRepository);
}
}

View file

@ -0,0 +1,94 @@
package de.effigenix.infrastructure.production.persistence.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "recipes",
uniqueConstraints = @UniqueConstraint(name = "uq_recipe_name_version", columnNames = {"name", "version"}))
public class RecipeEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "version", nullable = false)
private int version;
@Column(name = "type", nullable = false, length = 30)
private String type;
@Column(name = "description", length = 2000)
private String description;
@Column(name = "yield_percentage", nullable = false)
private int yieldPercentage;
@Column(name = "shelf_life_days")
private Integer shelfLifeDays;
@Column(name = "output_quantity", nullable = false, precision = 19, scale = 6)
private BigDecimal outputQuantity;
@Column(name = "output_uom", nullable = false, length = 20)
private String outputUom;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
protected RecipeEntity() {}
public RecipeEntity(String id, String name, int version, String type, String description,
int yieldPercentage, Integer shelfLifeDays, BigDecimal outputQuantity,
String outputUom, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.id = id;
this.name = name;
this.version = version;
this.type = type;
this.description = description;
this.yieldPercentage = yieldPercentage;
this.shelfLifeDays = shelfLifeDays;
this.outputQuantity = outputQuantity;
this.outputUom = outputUom;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public String getId() { return id; }
public String getName() { return name; }
public int getVersion() { return version; }
public String getType() { return type; }
public String getDescription() { return description; }
public int getYieldPercentage() { return yieldPercentage; }
public Integer getShelfLifeDays() { return shelfLifeDays; }
public BigDecimal getOutputQuantity() { return outputQuantity; }
public String getOutputUom() { return outputUom; }
public String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setVersion(int version) { this.version = version; }
public void setType(String type) { this.type = type; }
public void setDescription(String description) { this.description = description; }
public void setYieldPercentage(int yieldPercentage) { this.yieldPercentage = yieldPercentage; }
public void setShelfLifeDays(Integer shelfLifeDays) { this.shelfLifeDays = shelfLifeDays; }
public void setOutputQuantity(BigDecimal outputQuantity) { this.outputQuantity = outputQuantity; }
public void setOutputUom(String outputUom) { this.outputUom = outputUom; }
public void setStatus(String status) { this.status = status; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View file

@ -0,0 +1,46 @@
package de.effigenix.infrastructure.production.persistence.mapper;
import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import org.springframework.stereotype.Component;
@Component
public class RecipeMapper {
public RecipeEntity toEntity(Recipe recipe) {
return new RecipeEntity(
recipe.id().value(),
recipe.name().value(),
recipe.version(),
recipe.type().name(),
recipe.description(),
recipe.yieldPercentage().value(),
recipe.shelfLifeDays(),
recipe.outputQuantity().amount(),
recipe.outputQuantity().uom().name(),
recipe.status().name(),
recipe.createdAt(),
recipe.updatedAt()
);
}
public Recipe toDomain(RecipeEntity entity) {
return Recipe.reconstitute(
RecipeId.of(entity.getId()),
new RecipeName(entity.getName()),
entity.getVersion(),
RecipeType.valueOf(entity.getType()),
entity.getDescription(),
new YieldPercentage(entity.getYieldPercentage()),
entity.getShelfLifeDays(),
Quantity.reconstitute(
entity.getOutputQuantity(),
UnitOfMeasure.valueOf(entity.getOutputUom()),
null, null
),
RecipeStatus.valueOf(entity.getStatus()),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View file

@ -0,0 +1,90 @@
package de.effigenix.infrastructure.production.persistence.repository;
import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.mapper.RecipeMapper;
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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
@Profile("!no-db")
@Transactional(readOnly = true)
public class JpaRecipeRepository implements RecipeRepository {
private static final Logger logger = LoggerFactory.getLogger(JpaRecipeRepository.class);
private final RecipeJpaRepository jpaRepository;
private final RecipeMapper mapper;
public JpaRecipeRepository(RecipeJpaRepository jpaRepository, RecipeMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Result<RepositoryError, Optional<Recipe>> findById(RecipeId id) {
try {
Optional<Recipe> 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<Recipe>> findAll() {
try {
List<Recipe> result = jpaRepository.findAll().stream()
.map(mapper::toDomain)
.collect(Collectors.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(Recipe recipe) {
try {
jpaRepository.save(mapper.toEntity(recipe));
return Result.success(null);
} catch (Exception e) {
logger.trace("Database error in save", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
@Transactional
public Result<RepositoryError, Void> delete(Recipe recipe) {
try {
jpaRepository.deleteById(recipe.id().value());
return Result.success(null);
} catch (Exception e) {
logger.trace("Database error in delete", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
try {
return Result.success(jpaRepository.existsByNameAndVersion(name, version));
} catch (Exception e) {
logger.trace("Database error in existsByNameAndVersion", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
}

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.production.persistence.repository;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface RecipeJpaRepository extends JpaRepository<RecipeEntity, String> {
List<RecipeEntity> findByStatus(String status);
boolean existsByNameAndVersion(String name, int version);
}

View file

@ -0,0 +1,77 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.domain.production.RecipeError;
import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest;
import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
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/recipes")
@SecurityRequirement(name = "Bearer Authentication")
@Tag(name = "Recipes", description = "Recipe management endpoints")
public class RecipeController {
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
private final CreateRecipe createRecipe;
public RecipeController(CreateRecipe createRecipe) {
this.createRecipe = createRecipe;
}
@PostMapping
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<RecipeResponse> createRecipe(
@Valid @RequestBody CreateRecipeRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Creating recipe: {} v{} by actor: {}", request.name(), request.version(), actorId.value());
var cmd = new CreateRecipeCommand(
request.name(), request.version(), request.type(), request.description(),
request.yieldPercentage(), request.shelfLifeDays(),
request.outputQuantity(), request.outputUom()
);
var result = createRecipe.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Recipe created: {} v{}", request.name(), request.version());
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue()));
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");
}
return ActorId.of(authentication.getName());
}
public static class RecipeDomainErrorException extends RuntimeException {
private final RecipeError error;
public RecipeDomainErrorException(RecipeError error) {
super(error.message());
this.error = error;
}
public RecipeError getError() {
return error;
}
}
}

View file

@ -0,0 +1,16 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.RecipeType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateRecipeRequest(
@NotBlank String name,
int version,
@NotNull RecipeType type,
String description,
int yieldPercentage,
Integer shelfLifeDays,
@NotBlank String outputQuantity,
@NotBlank String outputUom
) {}

View file

@ -0,0 +1,37 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Recipe;
import java.time.LocalDateTime;
public record RecipeResponse(
String id,
String name,
int version,
String type,
String description,
int yieldPercentage,
Integer shelfLifeDays,
String outputQuantity,
String outputUom,
String status,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static RecipeResponse from(Recipe recipe) {
return new RecipeResponse(
recipe.id().value(),
recipe.name().value(),
recipe.version(),
recipe.type().name(),
recipe.description(),
recipe.yieldPercentage().value(),
recipe.shelfLifeDays(),
recipe.outputQuantity().amount().toPlainString(),
recipe.outputQuantity().uom().name(),
recipe.status().name(),
recipe.createdAt(),
recipe.updatedAt()
);
}
}

View file

@ -0,0 +1,19 @@
package de.effigenix.infrastructure.production.web.exception;
import de.effigenix.domain.production.RecipeError;
public final class ProductionErrorHttpStatusMapper {
private ProductionErrorHttpStatusMapper() {}
public static int toHttpStatus(RecipeError error) {
return switch (error) {
case RecipeError.RecipeNotFound e -> 404;
case RecipeError.NameAndVersionAlreadyExists e -> 409;
case RecipeError.InvalidShelfLife e -> 400;
case RecipeError.ValidationFailure e -> 400;
case RecipeError.Unauthorized e -> 403;
case RecipeError.RepositoryFailure e -> 500;
};
}
}

View file

@ -5,6 +5,7 @@ import de.effigenix.domain.masterdata.ArticleError;
import de.effigenix.domain.masterdata.ProductCategoryError; 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.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;
import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper; import de.effigenix.infrastructure.inventory.web.exception.InventoryErrorHttpStatusMapper;
@ -12,6 +13,8 @@ import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController; 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.RecipeController;
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper; import de.effigenix.infrastructure.masterdata.web.exception.MasterDataErrorHttpStatusMapper;
import de.effigenix.infrastructure.usermanagement.web.controller.AuthController; import de.effigenix.infrastructure.usermanagement.web.controller.AuthController;
@ -201,6 +204,25 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status).body(errorResponse); return ResponseEntity.status(status).body(errorResponse);
} }
@ExceptionHandler(RecipeController.RecipeDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleRecipeDomainError(
RecipeController.RecipeDomainErrorException ex,
HttpServletRequest request
) {
RecipeError error = ex.getError();
int status = ProductionErrorHttpStatusMapper.toHttpStatus(error);
logDomainError("Recipe", error.code(), error.message(), status);
ErrorResponse errorResponse = ErrorResponse.from(
error.code(),
error.message(),
status,
request.getRequestURI()
);
return ResponseEntity.status(status).body(errorResponse);
}
@ExceptionHandler(RoleController.RoleDomainErrorException.class) @ExceptionHandler(RoleController.RoleDomainErrorException.class)
public ResponseEntity<ErrorResponse> handleRoleDomainError( public ResponseEntity<ErrorResponse> handleRoleDomainError(
RoleController.RoleDomainErrorException ex, RoleController.RoleDomainErrorException ex,

View file

@ -0,0 +1,63 @@
<?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="010-create-recipes-table" author="effigenix">
<createTable tableName="recipes">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="version" type="INT">
<constraints nullable="false"/>
</column>
<column name="type" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(2000)"/>
<column name="yield_percentage" type="INT">
<constraints nullable="false"/>
</column>
<column name="shelf_life_days" type="INT"/>
<column name="output_quantity" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="output_uom" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="DRAFT">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<addUniqueConstraint tableName="recipes" columnNames="name, version"
constraintName="uq_recipe_name_version"/>
<sql>
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_type CHECK (type IN ('RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'));
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_status CHECK (status IN ('DRAFT', 'ACTIVE', 'ARCHIVED'));
ALTER TABLE recipes ADD CONSTRAINT chk_recipe_yield CHECK (yield_percentage BETWEEN 1 AND 200);
</sql>
<createIndex tableName="recipes" indexName="idx_recipes_name_version">
<column name="name"/>
<column name="version"/>
</createIndex>
<createIndex tableName="recipes" indexName="idx_recipes_status">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -14,5 +14,6 @@
<include file="db/changelog/changes/007-create-customer-schema.xml"/> <include file="db/changelog/changes/007-create-customer-schema.xml"/>
<include file="db/changelog/changes/008-add-masterdata-permissions.xml"/> <include file="db/changelog/changes/008-add-masterdata-permissions.xml"/>
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/> <include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,230 @@
package de.effigenix.domain.production;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Recipe Aggregate")
class RecipeTest {
private RecipeDraft validDraft() {
return new RecipeDraft(
"Bratwurst Grob", 1, RecipeType.FINISHED_PRODUCT,
"Grobe Bratwurst nach Hausrezept", 85,
14, "100", "KILOGRAM"
);
}
@Nested
@DisplayName("create()")
class Create {
@Test
@DisplayName("should create recipe in DRAFT status with valid inputs")
void should_CreateInDraftStatus_When_ValidInputs() {
var result = Recipe.create(validDraft());
assertThat(result.isSuccess()).isTrue();
var recipe = result.unsafeGetValue();
assertThat(recipe.id()).isNotNull();
assertThat(recipe.name().value()).isEqualTo("Bratwurst Grob");
assertThat(recipe.version()).isEqualTo(1);
assertThat(recipe.type()).isEqualTo(RecipeType.FINISHED_PRODUCT);
assertThat(recipe.description()).isEqualTo("Grobe Bratwurst nach Hausrezept");
assertThat(recipe.yieldPercentage().value()).isEqualTo(85);
assertThat(recipe.shelfLifeDays()).isEqualTo(14);
assertThat(recipe.outputQuantity().amount()).isEqualByComparingTo("100");
assertThat(recipe.outputQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(recipe.status()).isEqualTo(RecipeStatus.DRAFT);
assertThat(recipe.createdAt()).isNotNull();
assertThat(recipe.updatedAt()).isNotNull();
}
@Test
@DisplayName("should fail when name is blank")
void should_Fail_When_NameIsBlank() {
var draft = new RecipeDraft(
"", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when name is null")
void should_Fail_When_NameIsNull() {
var draft = new RecipeDraft(
null, 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when version is less than 1")
void should_Fail_When_VersionLessThanOne() {
var draft = new RecipeDraft(
"Test", 0, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when yield percentage is 0")
void should_Fail_When_YieldPercentageIsZero() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 0, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when yield percentage is 201")
void should_Fail_When_YieldPercentageIs201() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 201, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when shelf life is 0 for FINISHED_PRODUCT")
void should_Fail_When_ShelfLifeZeroForFinishedProduct() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 0, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
}
@Test
@DisplayName("should fail when shelf life is null for FINISHED_PRODUCT")
void should_Fail_When_ShelfLifeNullForFinishedProduct() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, null, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
}
@Test
@DisplayName("should fail when shelf life is 0 for INTERMEDIATE")
void should_Fail_When_ShelfLifeZeroForIntermediate() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.INTERMEDIATE, null, 85, 0, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.InvalidShelfLife.class);
}
@Test
@DisplayName("should allow null shelf life for RAW_MATERIAL")
void should_AllowNullShelfLife_When_RawMaterial() {
var draft = new RecipeDraft(
"Schweinefleisch", 1, RecipeType.RAW_MATERIAL, null, 100, null, "50", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().shelfLifeDays()).isNull();
}
@Test
@DisplayName("should fail when output quantity is zero")
void should_Fail_When_OutputQuantityZero() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "0", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should fail when output UoM is invalid")
void should_Fail_When_OutputUomInvalid() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "INVALID"
);
var result = Recipe.create(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(RecipeError.ValidationFailure.class);
}
@Test
@DisplayName("should allow description to be null")
void should_AllowNullDescription() {
var draft = new RecipeDraft(
"Test", 1, RecipeType.FINISHED_PRODUCT, null, 85, 14, "100", "KILOGRAM"
);
var result = Recipe.create(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().description()).isNull();
}
}
@Nested
@DisplayName("Equality")
class Equality {
@Test
@DisplayName("should be equal when same ID")
void should_BeEqual_When_SameId() {
var recipe1 = Recipe.create(validDraft()).unsafeGetValue();
var recipe2 = Recipe.reconstitute(
recipe1.id(), new RecipeName("Other"), 2, RecipeType.RAW_MATERIAL,
null, new YieldPercentage(100), null,
Quantity.of(new java.math.BigDecimal("50"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
RecipeStatus.ACTIVE, recipe1.createdAt(), recipe1.updatedAt()
);
assertThat(recipe1).isEqualTo(recipe2);
assertThat(recipe1.hashCode()).isEqualTo(recipe2.hashCode());
}
@Test
@DisplayName("should not be equal when different ID")
void should_NotBeEqual_When_DifferentId() {
var recipe1 = Recipe.create(validDraft()).unsafeGetValue();
var recipe2 = Recipe.create(validDraft()).unsafeGetValue();
assertThat(recipe1).isNotEqualTo(recipe2);
}
}
}