mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:19:35 +01:00
feat(production): Charge planen (PlanBatch) (#33)
Batch-Aggregat mit plan()-Factory, automatischer BatchNumber-Generierung (P-YYYY-MM-DD-XXX) und Validierung (Quantity > 0, bestBeforeDate > productionDate). Full Vertical Slice: Domain, Application, Infrastructure (JPA, REST, Liquibase).
This commit is contained in:
parent
d963d7fccc
commit
b06157b92c
25 changed files with 1541 additions and 0 deletions
|
|
@ -0,0 +1,86 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.PlanBatchCommand;
|
||||
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 PlanBatch {
|
||||
|
||||
private final BatchRepository batchRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final BatchNumberGenerator batchNumberGenerator;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
|
||||
public PlanBatch(
|
||||
BatchRepository batchRepository,
|
||||
RecipeRepository recipeRepository,
|
||||
BatchNumberGenerator batchNumberGenerator,
|
||||
AuthorizationPort authorizationPort
|
||||
) {
|
||||
this.batchRepository = batchRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.batchNumberGenerator = batchNumberGenerator;
|
||||
this.authorizationPort = authorizationPort;
|
||||
}
|
||||
|
||||
public Result<BatchError, Batch> execute(PlanBatchCommand cmd, ActorId performedBy) {
|
||||
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_WRITE)) {
|
||||
return Result.failure(new BatchError.Unauthorized("Not authorized to plan batches"));
|
||||
}
|
||||
|
||||
// Verify recipe exists and is ACTIVE
|
||||
Recipe recipe;
|
||||
switch (recipeRepository.findById(RecipeId.of(cmd.recipeId()))) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new BatchError.RepositoryFailure(err.message()));
|
||||
}
|
||||
case Result.Success(var opt) -> {
|
||||
if (opt.isEmpty()) {
|
||||
return Result.failure(new BatchError.ValidationFailure(
|
||||
"Recipe with ID '" + cmd.recipeId() + "' not found"));
|
||||
}
|
||||
recipe = opt.get();
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe.status() != RecipeStatus.ACTIVE) {
|
||||
return Result.failure(new BatchError.RecipeNotActive(recipe.id()));
|
||||
}
|
||||
|
||||
// Generate batch number
|
||||
BatchNumber batchNumber;
|
||||
switch (batchNumberGenerator.generateNext(cmd.productionDate())) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> batchNumber = val;
|
||||
}
|
||||
|
||||
// Plan batch
|
||||
var draft = new BatchDraft(
|
||||
cmd.recipeId(),
|
||||
cmd.plannedQuantity(),
|
||||
cmd.plannedQuantityUnit(),
|
||||
cmd.productionDate(),
|
||||
cmd.bestBeforeDate()
|
||||
);
|
||||
|
||||
Batch batch;
|
||||
switch (Batch.plan(draft, batchNumber)) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var val) -> batch = val;
|
||||
}
|
||||
|
||||
// Persist
|
||||
switch (batchRepository.save(batch)) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new BatchError.RepositoryFailure(err.message()));
|
||||
}
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
return Result.success(batch);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record PlanBatchCommand(
|
||||
String recipeId,
|
||||
String plannedQuantity,
|
||||
String plannedQuantityUnit,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate
|
||||
) {}
|
||||
128
backend/src/main/java/de/effigenix/domain/production/Batch.java
Normal file
128
backend/src/main/java/de/effigenix/domain/production/Batch.java
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Batch aggregate root.
|
||||
*
|
||||
* Invariants:
|
||||
* 1. PlannedQuantity must be positive
|
||||
* 2. BestBeforeDate must be after ProductionDate
|
||||
* 3. BatchNumber is auto-generated (format P-YYYY-MM-DD-XXX)
|
||||
* 4. New batches always start in PLANNED status
|
||||
* 5. RecipeId must reference an ACTIVE recipe (enforced by Use Case)
|
||||
*/
|
||||
public class Batch {
|
||||
|
||||
private final BatchId id;
|
||||
private final BatchNumber batchNumber;
|
||||
private final RecipeId recipeId;
|
||||
private BatchStatus status;
|
||||
private final Quantity plannedQuantity;
|
||||
private final LocalDate productionDate;
|
||||
private final LocalDate bestBeforeDate;
|
||||
private final LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
private Batch(
|
||||
BatchId id,
|
||||
BatchNumber batchNumber,
|
||||
RecipeId recipeId,
|
||||
BatchStatus status,
|
||||
Quantity plannedQuantity,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
this.id = id;
|
||||
this.batchNumber = batchNumber;
|
||||
this.recipeId = recipeId;
|
||||
this.status = status;
|
||||
this.plannedQuantity = plannedQuantity;
|
||||
this.productionDate = productionDate;
|
||||
this.bestBeforeDate = bestBeforeDate;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public static Result<BatchError, Batch> plan(BatchDraft draft, BatchNumber batchNumber) {
|
||||
if (draft.recipeId() == null || draft.recipeId().isBlank()) {
|
||||
return Result.failure(new BatchError.ValidationFailure("recipeId must not be blank"));
|
||||
}
|
||||
|
||||
if (draft.productionDate() == null) {
|
||||
return Result.failure(new BatchError.ValidationFailure("productionDate must not be null"));
|
||||
}
|
||||
|
||||
if (draft.bestBeforeDate() == null) {
|
||||
return Result.failure(new BatchError.ValidationFailure("bestBeforeDate must not be null"));
|
||||
}
|
||||
|
||||
if (!draft.bestBeforeDate().isAfter(draft.productionDate())) {
|
||||
return Result.failure(new BatchError.InvalidDates(
|
||||
"bestBeforeDate (" + draft.bestBeforeDate() + ") must be after productionDate (" + draft.productionDate() + ")"));
|
||||
}
|
||||
|
||||
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 BatchError.InvalidPlannedQuantity(err.toString()));
|
||||
}
|
||||
case Result.Success(var qty) -> plannedQuantity = qty;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return Result.failure(new BatchError.InvalidPlannedQuantity(
|
||||
"Invalid amount format: " + draft.plannedQuantity()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new BatchError.InvalidPlannedQuantity(
|
||||
"Invalid unit: " + draft.plannedQuantityUnit()));
|
||||
}
|
||||
|
||||
var now = LocalDateTime.now();
|
||||
return Result.success(new Batch(
|
||||
BatchId.generate(),
|
||||
batchNumber,
|
||||
RecipeId.of(draft.recipeId()),
|
||||
BatchStatus.PLANNED,
|
||||
plannedQuantity,
|
||||
draft.productionDate(),
|
||||
draft.bestBeforeDate(),
|
||||
now,
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
public static Batch reconstitute(
|
||||
BatchId id,
|
||||
BatchNumber batchNumber,
|
||||
RecipeId recipeId,
|
||||
BatchStatus status,
|
||||
Quantity plannedQuantity,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate, bestBeforeDate, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
public BatchId id() { return id; }
|
||||
public BatchNumber batchNumber() { return batchNumber; }
|
||||
public RecipeId recipeId() { return recipeId; }
|
||||
public BatchStatus status() { return status; }
|
||||
public Quantity plannedQuantity() { return plannedQuantity; }
|
||||
public LocalDate productionDate() { return productionDate; }
|
||||
public LocalDate bestBeforeDate() { return bestBeforeDate; }
|
||||
public LocalDateTime createdAt() { return createdAt; }
|
||||
public LocalDateTime updatedAt() { return updatedAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record BatchDraft(
|
||||
String recipeId,
|
||||
String plannedQuantity,
|
||||
String plannedQuantityUnit,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate
|
||||
) {}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
public sealed interface BatchError {
|
||||
|
||||
String code();
|
||||
String message();
|
||||
|
||||
record BatchNotFound(BatchId id) implements BatchError {
|
||||
@Override public String code() { return "BATCH_NOT_FOUND"; }
|
||||
@Override public String message() { return "Batch with ID '" + id.value() + "' not found"; }
|
||||
}
|
||||
|
||||
record InvalidPlannedQuantity(String reason) implements BatchError {
|
||||
@Override public String code() { return "BATCH_INVALID_PLANNED_QUANTITY"; }
|
||||
@Override public String message() { return "Invalid planned quantity: " + reason; }
|
||||
}
|
||||
|
||||
record InvalidDates(String reason) implements BatchError {
|
||||
@Override public String code() { return "BATCH_INVALID_DATES"; }
|
||||
@Override public String message() { return "Invalid dates: " + reason; }
|
||||
}
|
||||
|
||||
record RecipeNotActive(RecipeId recipeId) implements BatchError {
|
||||
@Override public String code() { return "BATCH_RECIPE_NOT_ACTIVE"; }
|
||||
@Override public String message() { return "Recipe '" + recipeId.value() + "' is not in ACTIVE status"; }
|
||||
}
|
||||
|
||||
record ValidationFailure(String message) implements BatchError {
|
||||
@Override public String code() { return "BATCH_VALIDATION_ERROR"; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements BatchError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
||||
record RepositoryFailure(String message) implements BatchError {
|
||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record BatchId(String value) {
|
||||
|
||||
public BatchId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("BatchId must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public static BatchId generate() {
|
||||
return new BatchId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public static BatchId of(String value) {
|
||||
return new BatchId(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface BatchNumberGenerator {
|
||||
|
||||
Result<BatchError, BatchNumber> generateNext(LocalDate date);
|
||||
}
|
||||
|
|
@ -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 BatchRepository {
|
||||
|
||||
Result<RepositoryError, Optional<Batch>> findById(BatchId id);
|
||||
|
||||
Result<RepositoryError, List<Batch>> findAll();
|
||||
|
||||
Result<RepositoryError, Void> save(Batch batch);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package de.effigenix.domain.production;
|
||||
|
||||
public enum BatchStatus {
|
||||
PLANNED,
|
||||
IN_PRODUCTION,
|
||||
COMPLETED,
|
||||
CANCELLED
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ import de.effigenix.application.production.ArchiveRecipe;
|
|||
import de.effigenix.application.production.AddProductionStep;
|
||||
import de.effigenix.application.production.AddRecipeIngredient;
|
||||
import de.effigenix.application.production.CreateRecipe;
|
||||
import de.effigenix.application.production.PlanBatch;
|
||||
import de.effigenix.application.production.RecipeCycleChecker;
|
||||
import de.effigenix.application.production.GetRecipe;
|
||||
import de.effigenix.application.production.ListRecipes;
|
||||
import de.effigenix.application.production.RemoveProductionStep;
|
||||
import de.effigenix.application.production.RemoveRecipeIngredient;
|
||||
import de.effigenix.domain.production.BatchNumberGenerator;
|
||||
import de.effigenix.domain.production.BatchRepository;
|
||||
import de.effigenix.domain.production.RecipeRepository;
|
||||
import de.effigenix.shared.security.AuthorizationPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
|
@ -68,4 +71,10 @@ public class ProductionUseCaseConfiguration {
|
|||
public ArchiveRecipe archiveRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
|
||||
return new ArchiveRecipe(recipeRepository, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PlanBatch planBatch(BatchRepository batchRepository, RecipeRepository recipeRepository,
|
||||
BatchNumberGenerator batchNumberGenerator, AuthorizationPort authorizationPort) {
|
||||
return new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authorizationPort);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
package de.effigenix.infrastructure.production.persistence;
|
||||
|
||||
import de.effigenix.domain.production.BatchError;
|
||||
import de.effigenix.domain.production.BatchNumber;
|
||||
import de.effigenix.domain.production.BatchNumberGenerator;
|
||||
import de.effigenix.infrastructure.production.persistence.repository.BatchJpaRepository;
|
||||
import de.effigenix.shared.common.Result;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Component
|
||||
@Profile("!no-db")
|
||||
public class JpaBatchNumberGenerator implements BatchNumberGenerator {
|
||||
|
||||
private final BatchJpaRepository batchJpaRepository;
|
||||
|
||||
public JpaBatchNumberGenerator(BatchJpaRepository batchJpaRepository) {
|
||||
this.batchJpaRepository = batchJpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<BatchError, BatchNumber> generateNext(LocalDate date) {
|
||||
try {
|
||||
int count = batchJpaRepository.countByProductionDate(date);
|
||||
int nextSequence = count + 1;
|
||||
if (nextSequence > 999) {
|
||||
return Result.failure(new BatchError.ValidationFailure(
|
||||
"Maximum batch number sequence (999) reached for date " + date));
|
||||
}
|
||||
return Result.success(BatchNumber.generate(date, nextSequence));
|
||||
} catch (Exception e) {
|
||||
return Result.failure(new BatchError.RepositoryFailure(e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package de.effigenix.infrastructure.production.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "batches")
|
||||
public class BatchEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 36)
|
||||
private String id;
|
||||
|
||||
@Column(name = "batch_number", nullable = false, unique = true, length = 20)
|
||||
private String batchNumber;
|
||||
|
||||
@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 = "production_date", nullable = false)
|
||||
private LocalDate productionDate;
|
||||
|
||||
@Column(name = "best_before_date", nullable = false)
|
||||
private LocalDate bestBeforeDate;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
protected BatchEntity() {}
|
||||
|
||||
public BatchEntity(
|
||||
String id,
|
||||
String batchNumber,
|
||||
String recipeId,
|
||||
String status,
|
||||
BigDecimal plannedQuantityAmount,
|
||||
String plannedQuantityUnit,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
this.id = id;
|
||||
this.batchNumber = batchNumber;
|
||||
this.recipeId = recipeId;
|
||||
this.status = status;
|
||||
this.plannedQuantityAmount = plannedQuantityAmount;
|
||||
this.plannedQuantityUnit = plannedQuantityUnit;
|
||||
this.productionDate = productionDate;
|
||||
this.bestBeforeDate = bestBeforeDate;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getBatchNumber() { return batchNumber; }
|
||||
public String getRecipeId() { return recipeId; }
|
||||
public String getStatus() { return status; }
|
||||
public BigDecimal getPlannedQuantityAmount() { return plannedQuantityAmount; }
|
||||
public String getPlannedQuantityUnit() { return plannedQuantityUnit; }
|
||||
public LocalDate getProductionDate() { return productionDate; }
|
||||
public LocalDate getBestBeforeDate() { return bestBeforeDate; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package de.effigenix.infrastructure.production.persistence.mapper;
|
||||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.BatchEntity;
|
||||
import de.effigenix.shared.common.Quantity;
|
||||
import de.effigenix.shared.common.UnitOfMeasure;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class BatchMapper {
|
||||
|
||||
public BatchEntity toEntity(Batch batch) {
|
||||
return new BatchEntity(
|
||||
batch.id().value(),
|
||||
batch.batchNumber().value(),
|
||||
batch.recipeId().value(),
|
||||
batch.status().name(),
|
||||
batch.plannedQuantity().amount(),
|
||||
batch.plannedQuantity().uom().name(),
|
||||
batch.productionDate(),
|
||||
batch.bestBeforeDate(),
|
||||
batch.createdAt(),
|
||||
batch.updatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public Batch toDomain(BatchEntity entity) {
|
||||
return Batch.reconstitute(
|
||||
BatchId.of(entity.getId()),
|
||||
new BatchNumber(entity.getBatchNumber()),
|
||||
RecipeId.of(entity.getRecipeId()),
|
||||
BatchStatus.valueOf(entity.getStatus()),
|
||||
Quantity.reconstitute(
|
||||
entity.getPlannedQuantityAmount(),
|
||||
UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()),
|
||||
null, null
|
||||
),
|
||||
entity.getProductionDate(),
|
||||
entity.getBestBeforeDate(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package de.effigenix.infrastructure.production.persistence.repository;
|
||||
|
||||
import de.effigenix.infrastructure.production.persistence.entity.BatchEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface BatchJpaRepository extends JpaRepository<BatchEntity, String> {
|
||||
|
||||
@Query("SELECT COUNT(b) FROM BatchEntity b WHERE b.productionDate = :date")
|
||||
int countByProductionDate(LocalDate date);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package de.effigenix.infrastructure.production.persistence.repository;
|
||||
|
||||
import de.effigenix.domain.production.*;
|
||||
import de.effigenix.infrastructure.production.persistence.mapper.BatchMapper;
|
||||
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 JpaBatchRepository implements BatchRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JpaBatchRepository.class);
|
||||
|
||||
private final BatchJpaRepository jpaRepository;
|
||||
private final BatchMapper mapper;
|
||||
|
||||
public JpaBatchRepository(BatchJpaRepository jpaRepository, BatchMapper mapper) {
|
||||
this.jpaRepository = jpaRepository;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Optional<Batch>> findById(BatchId id) {
|
||||
try {
|
||||
Optional<Batch> 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<Batch>> findAll() {
|
||||
try {
|
||||
List<Batch> 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(Batch batch) {
|
||||
try {
|
||||
jpaRepository.save(mapper.toEntity(batch));
|
||||
return Result.success(null);
|
||||
} catch (Exception e) {
|
||||
logger.trace("Database error in save", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package de.effigenix.infrastructure.production.web.controller;
|
||||
|
||||
import de.effigenix.application.production.PlanBatch;
|
||||
import de.effigenix.application.production.command.PlanBatchCommand;
|
||||
import de.effigenix.domain.production.BatchError;
|
||||
import de.effigenix.infrastructure.production.web.dto.BatchResponse;
|
||||
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
|
||||
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/batches")
|
||||
@SecurityRequirement(name = "Bearer Authentication")
|
||||
@Tag(name = "Batches", description = "Production batch management endpoints")
|
||||
public class BatchController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BatchController.class);
|
||||
|
||||
private final PlanBatch planBatch;
|
||||
|
||||
public BatchController(PlanBatch planBatch) {
|
||||
this.planBatch = planBatch;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('BATCH_WRITE')")
|
||||
public ResponseEntity<BatchResponse> planBatch(
|
||||
@Valid @RequestBody PlanBatchRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
logger.info("Planning batch for recipe: {} by actor: {}", request.recipeId(), authentication.getName());
|
||||
|
||||
var cmd = new PlanBatchCommand(
|
||||
request.recipeId(),
|
||||
request.plannedQuantity(),
|
||||
request.plannedQuantityUnit(),
|
||||
request.productionDate(),
|
||||
request.bestBeforeDate()
|
||||
);
|
||||
|
||||
var result = planBatch.execute(cmd, ActorId.of(authentication.getName()));
|
||||
|
||||
if (result.isFailure()) {
|
||||
throw new BatchDomainErrorException(result.unsafeGetError());
|
||||
}
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(BatchResponse.from(result.unsafeGetValue()));
|
||||
}
|
||||
|
||||
public static class BatchDomainErrorException extends RuntimeException {
|
||||
private final BatchError error;
|
||||
|
||||
public BatchDomainErrorException(BatchError error) {
|
||||
super(error.message());
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public BatchError getError() {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import de.effigenix.domain.production.Batch;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record BatchResponse(
|
||||
String id,
|
||||
String batchNumber,
|
||||
String recipeId,
|
||||
String status,
|
||||
String plannedQuantity,
|
||||
String plannedQuantityUnit,
|
||||
LocalDate productionDate,
|
||||
LocalDate bestBeforeDate,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
public static BatchResponse from(Batch batch) {
|
||||
return new BatchResponse(
|
||||
batch.id().value(),
|
||||
batch.batchNumber().value(),
|
||||
batch.recipeId().value(),
|
||||
batch.status().name(),
|
||||
batch.plannedQuantity().amount().toPlainString(),
|
||||
batch.plannedQuantity().uom().name(),
|
||||
batch.productionDate(),
|
||||
batch.bestBeforeDate(),
|
||||
batch.createdAt(),
|
||||
batch.updatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record PlanBatchRequest(
|
||||
@NotBlank String recipeId,
|
||||
@NotBlank String plannedQuantity,
|
||||
@NotBlank String plannedQuantityUnit,
|
||||
@NotNull LocalDate productionDate,
|
||||
@NotNull LocalDate bestBeforeDate
|
||||
) {}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package de.effigenix.infrastructure.production.web.exception;
|
||||
|
||||
import de.effigenix.domain.production.BatchError;
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
|
||||
public final class ProductionErrorHttpStatusMapper {
|
||||
|
|
@ -24,4 +25,16 @@ public final class ProductionErrorHttpStatusMapper {
|
|||
case RecipeError.RepositoryFailure e -> 500;
|
||||
};
|
||||
}
|
||||
|
||||
public static int toHttpStatus(BatchError error) {
|
||||
return switch (error) {
|
||||
case BatchError.BatchNotFound e -> 404;
|
||||
case BatchError.InvalidPlannedQuantity e -> 400;
|
||||
case BatchError.InvalidDates e -> 400;
|
||||
case BatchError.RecipeNotActive e -> 409;
|
||||
case BatchError.ValidationFailure e -> 400;
|
||||
case BatchError.Unauthorized e -> 403;
|
||||
case BatchError.RepositoryFailure e -> 500;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import de.effigenix.domain.masterdata.ArticleError;
|
|||
import de.effigenix.domain.masterdata.ProductCategoryError;
|
||||
import de.effigenix.domain.masterdata.CustomerError;
|
||||
import de.effigenix.domain.masterdata.SupplierError;
|
||||
import de.effigenix.domain.production.BatchError;
|
||||
import de.effigenix.domain.production.RecipeError;
|
||||
import de.effigenix.domain.usermanagement.UserError;
|
||||
import de.effigenix.infrastructure.inventory.web.controller.StorageLocationController;
|
||||
|
|
@ -15,6 +16,7 @@ import de.effigenix.infrastructure.masterdata.web.controller.ArticleController;
|
|||
import de.effigenix.infrastructure.masterdata.web.controller.CustomerController;
|
||||
import de.effigenix.infrastructure.masterdata.web.controller.ProductCategoryController;
|
||||
import de.effigenix.infrastructure.masterdata.web.controller.SupplierController;
|
||||
import de.effigenix.infrastructure.production.web.controller.BatchController;
|
||||
import de.effigenix.infrastructure.production.web.controller.RecipeController;
|
||||
import de.effigenix.infrastructure.production.web.exception.ProductionErrorHttpStatusMapper;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
|
|
@ -248,6 +250,29 @@ public class GlobalExceptionHandler {
|
|||
return ResponseEntity.status(status).body(errorResponse);
|
||||
}
|
||||
|
||||
@ExceptionHandler(BatchController.BatchDomainErrorException.class)
|
||||
public ResponseEntity<ErrorResponse> handleBatchDomainError(
|
||||
BatchController.BatchDomainErrorException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
BatchError error = ex.getError();
|
||||
int status = ProductionErrorHttpStatusMapper.toHttpStatus(error);
|
||||
logDomainError("Batch", 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(RoleController.RoleDomainErrorException.class)
|
||||
public ResponseEntity<ErrorResponse> handleRoleDomainError(
|
||||
RoleController.RoleDomainErrorException ex,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?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="015-create-batches-table" author="effigenix">
|
||||
<createTable tableName="batches">
|
||||
<column name="id" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="batch_number" type="VARCHAR(20)">
|
||||
<constraints nullable="false" unique="true" uniqueConstraintName="uq_batch_number"/>
|
||||
</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="production_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="best_before_date" type="DATE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<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>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="batches"
|
||||
baseColumnNames="recipe_id"
|
||||
constraintName="fk_batch_recipe"
|
||||
referencedTableName="recipes"
|
||||
referencedColumnNames="id"/>
|
||||
|
||||
<createIndex tableName="batches" indexName="idx_batch_recipe_id">
|
||||
<column name="recipe_id"/>
|
||||
</createIndex>
|
||||
|
||||
<createIndex tableName="batches" indexName="idx_batch_production_date">
|
||||
<column name="production_date"/>
|
||||
</createIndex>
|
||||
|
||||
<sql>ALTER TABLE batches ADD CONSTRAINT chk_batch_status CHECK (status IN ('PLANNED', 'IN_PRODUCTION', 'COMPLETED', 'CANCELLED'));</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -19,5 +19,6 @@
|
|||
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
|
||||
<include file="db/changelog/changes/013-create-stock-schema.xml"/>
|
||||
<include file="db/changelog/changes/014-create-stock-batches-table.xml"/>
|
||||
<include file="db/changelog/changes/015-create-batches-table.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
package de.effigenix.application.production;
|
||||
|
||||
import de.effigenix.application.production.command.PlanBatchCommand;
|
||||
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.LocalDateTime;
|
||||
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("PlanBatch Use Case")
|
||||
class PlanBatchTest {
|
||||
|
||||
@Mock private BatchRepository batchRepository;
|
||||
@Mock private RecipeRepository recipeRepository;
|
||||
@Mock private BatchNumberGenerator batchNumberGenerator;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
|
||||
private PlanBatch planBatch;
|
||||
private ActorId performedBy;
|
||||
|
||||
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
|
||||
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
|
||||
private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
planBatch = new PlanBatch(batchRepository, recipeRepository, batchNumberGenerator, authPort);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
}
|
||||
|
||||
private PlanBatchCommand validCommand() {
|
||||
return new PlanBatchCommand("recipe-1", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
}
|
||||
|
||||
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(),
|
||||
RecipeStatus.ACTIVE, List.of(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now()
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
RecipeStatus.DRAFT, List.of(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should plan batch when recipe is ACTIVE and all data valid")
|
||||
void should_PlanBatch_When_ValidCommand() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(activeRecipe())));
|
||||
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
|
||||
.thenReturn(Result.success(BATCH_NUMBER));
|
||||
when(batchRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var batch = result.unsafeGetValue();
|
||||
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
|
||||
assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER);
|
||||
assertThat(batch.recipeId().value()).isEqualTo("recipe-1");
|
||||
verify(batchRepository).save(any(Batch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when actor lacks BATCH_WRITE permission")
|
||||
void should_Fail_When_Unauthorized() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(false);
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipe not found")
|
||||
void should_Fail_When_RecipeNotFound() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
assertThat(result.unsafeGetError().message()).contains("recipe-1");
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipe is not ACTIVE")
|
||||
void should_Fail_When_RecipeNotActive() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(draftRecipe())));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RecipeNotActive.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipe repository returns error")
|
||||
void should_Fail_When_RecipeRepositoryError() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batch number generation fails")
|
||||
void should_Fail_When_BatchNumberGenerationFails() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(activeRecipe())));
|
||||
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
|
||||
.thenReturn(Result.failure(new BatchError.ValidationFailure("Sequence exhausted")));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when batch repository save fails")
|
||||
void should_Fail_When_BatchSaveFails() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(activeRecipe())));
|
||||
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
|
||||
.thenReturn(Result.success(BATCH_NUMBER));
|
||||
when(batchRepository.save(any()))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("disk full")));
|
||||
|
||||
var result = planBatch.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when planned quantity is invalid")
|
||||
void should_Fail_When_InvalidQuantity() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(activeRecipe())));
|
||||
when(batchNumberGenerator.generateNext(PRODUCTION_DATE))
|
||||
.thenReturn(Result.success(BATCH_NUMBER));
|
||||
|
||||
var cmd = new PlanBatchCommand("recipe-1", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
var result = planBatch.execute(cmd, performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when dates are invalid")
|
||||
void should_Fail_When_InvalidDates() {
|
||||
when(authPort.can(performedBy, ProductionAction.BATCH_WRITE)).thenReturn(true);
|
||||
when(recipeRepository.findById(RecipeId.of("recipe-1")))
|
||||
.thenReturn(Result.success(Optional.of(activeRecipe())));
|
||||
when(batchNumberGenerator.generateNext(LocalDate.of(2026, 6, 1)))
|
||||
.thenReturn(Result.success(BatchNumber.generate(LocalDate.of(2026, 6, 1), 1)));
|
||||
|
||||
var cmd = new PlanBatchCommand("recipe-1", "100", "KILOGRAM",
|
||||
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
|
||||
var result = planBatch.execute(cmd, performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
|
||||
verify(batchRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
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.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("Batch Aggregate")
|
||||
class BatchTest {
|
||||
|
||||
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
|
||||
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
|
||||
private static final BatchNumber BATCH_NUMBER = BatchNumber.generate(PRODUCTION_DATE, 1);
|
||||
|
||||
private BatchDraft validDraft() {
|
||||
return new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("plan()")
|
||||
class Plan {
|
||||
|
||||
@Test
|
||||
@DisplayName("should create batch with valid draft")
|
||||
void should_CreateBatch_When_ValidDraft() {
|
||||
var result = Batch.plan(validDraft(), BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
var batch = result.unsafeGetValue();
|
||||
assertThat(batch.id()).isNotNull();
|
||||
assertThat(batch.batchNumber()).isEqualTo(BATCH_NUMBER);
|
||||
assertThat(batch.recipeId().value()).isEqualTo("recipe-123");
|
||||
assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED);
|
||||
assertThat(batch.plannedQuantity().amount()).isEqualByComparingTo(new BigDecimal("100"));
|
||||
assertThat(batch.plannedQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
|
||||
assertThat(batch.productionDate()).isEqualTo(PRODUCTION_DATE);
|
||||
assertThat(batch.bestBeforeDate()).isEqualTo(BEST_BEFORE_DATE);
|
||||
assertThat(batch.createdAt()).isNotNull();
|
||||
assertThat(batch.updatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipeId is blank")
|
||||
void should_Fail_When_RecipeIdBlank() {
|
||||
var draft = new BatchDraft("", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when recipeId is null")
|
||||
void should_Fail_When_RecipeIdNull() {
|
||||
var draft = new BatchDraft(null, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when plannedQuantity is zero")
|
||||
void should_Fail_When_QuantityZero() {
|
||||
var draft = new BatchDraft("recipe-123", "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when plannedQuantity is negative")
|
||||
void should_Fail_When_QuantityNegative() {
|
||||
var draft = new BatchDraft("recipe-123", "-5", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when plannedQuantity is not a number")
|
||||
void should_Fail_When_QuantityNotANumber() {
|
||||
var draft = new BatchDraft("recipe-123", "abc", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when unit is invalid")
|
||||
void should_Fail_When_UnitInvalid() {
|
||||
var draft = new BatchDraft("recipe-123", "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidPlannedQuantity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when bestBeforeDate is before productionDate")
|
||||
void should_Fail_When_BestBeforeDateBeforeProductionDate() {
|
||||
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM",
|
||||
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when bestBeforeDate equals productionDate")
|
||||
void should_Fail_When_BestBeforeDateEqualsProductionDate() {
|
||||
var sameDate = LocalDate.of(2026, 3, 1);
|
||||
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", sameDate, sameDate);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidDates.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when productionDate is null")
|
||||
void should_Fail_When_ProductionDateNull() {
|
||||
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", null, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when bestBeforeDate is null")
|
||||
void should_Fail_When_BestBeforeDateNull() {
|
||||
var draft = new BatchDraft("recipe-123", "100", "KILOGRAM", PRODUCTION_DATE, null);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.ValidationFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should accept decimal quantity")
|
||||
void should_Accept_When_DecimalQuantity() {
|
||||
var draft = new BatchDraft("recipe-123", "50.75", "LITER", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
var result = Batch.plan(draft, BATCH_NUMBER);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().plannedQuantity().amount())
|
||||
.isEqualByComparingTo(new BigDecimal("50.75"));
|
||||
assertThat(result.unsafeGetValue().plannedQuantity().uom()).isEqualTo(UnitOfMeasure.LITER);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("reconstitute()")
|
||||
class Reconstitute {
|
||||
|
||||
@Test
|
||||
@DisplayName("should reconstitute batch from persistence")
|
||||
void should_Reconstitute_When_ValidData() {
|
||||
var batch = Batch.reconstitute(
|
||||
BatchId.of("batch-1"),
|
||||
BATCH_NUMBER,
|
||||
RecipeId.of("recipe-123"),
|
||||
BatchStatus.IN_PRODUCTION,
|
||||
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
|
||||
PRODUCTION_DATE,
|
||||
BEST_BEFORE_DATE,
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now()
|
||||
);
|
||||
|
||||
assertThat(batch.id().value()).isEqualTo("batch-1");
|
||||
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
package de.effigenix.infrastructure.production.web;
|
||||
|
||||
import de.effigenix.domain.usermanagement.RoleName;
|
||||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
|
||||
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.RoleEntity;
|
||||
import de.effigenix.infrastructure.usermanagement.persistence.entity.UserEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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("Batch Controller Integration Tests")
|
||||
class BatchControllerIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private String adminToken;
|
||||
private String viewerToken;
|
||||
|
||||
private static final LocalDate PRODUCTION_DATE = LocalDate.of(2026, 3, 1);
|
||||
private static final LocalDate BEST_BEFORE_DATE = LocalDate.of(2026, 6, 1);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
RoleEntity adminRole = createRole(RoleName.ADMIN, "Admin");
|
||||
RoleEntity viewerRole = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||
|
||||
UserEntity admin = createUser("batch.admin", "batch.admin@test.com", Set.of(adminRole), "BRANCH-01");
|
||||
UserEntity viewer = createUser("batch.viewer", "batch.viewer@test.com", Set.of(viewerRole), "BRANCH-01");
|
||||
|
||||
adminToken = generateToken(admin.getId(), "batch.admin", "BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
|
||||
viewerToken = generateToken(viewer.getId(), "batch.viewer", "USER_READ");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("POST /api/production/batches – Charge planen")
|
||||
class PlanBatchEndpoint {
|
||||
|
||||
@Test
|
||||
@DisplayName("Charge planen mit gültigen Daten → 201")
|
||||
void planBatch_withValidData_returns201() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.batchNumber").isNotEmpty())
|
||||
.andExpect(jsonPath("$.recipeId").value(recipeId))
|
||||
.andExpect(jsonPath("$.status").value("PLANNED"))
|
||||
.andExpect(jsonPath("$.plannedQuantity").value("100.000000"))
|
||||
.andExpect(jsonPath("$.plannedQuantityUnit").value("KILOGRAM"))
|
||||
.andExpect(jsonPath("$.productionDate").value("2026-03-01"))
|
||||
.andExpect(jsonPath("$.bestBeforeDate").value("2026-06-01"))
|
||||
.andExpect(jsonPath("$.createdAt").isNotEmpty())
|
||||
.andExpect(jsonPath("$.updatedAt").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BatchNumber hat Format P-YYYY-MM-DD-XXX")
|
||||
void planBatch_batchNumberFormat() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Zweite Charge am selben Tag → inkrementierte Sequenznummer")
|
||||
void planBatch_secondBatchSameDay_incrementsSequence() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-001"));
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.batchNumber").value("P-2026-03-01-002"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PlannedQuantity 0 → 400")
|
||||
void planBatch_zeroQuantity_returns400() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "0", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Negative Menge → 400")
|
||||
void planBatch_negativeQuantity_returns400() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "-10", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ungültige Unit → 400")
|
||||
void planBatch_invalidUnit_returns400() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "INVALID_UNIT", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_PLANNED_QUANTITY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BestBeforeDate vor ProductionDate → 400")
|
||||
void planBatch_bestBeforeBeforeProduction_returns400() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "KILOGRAM",
|
||||
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 3, 1));
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("BestBeforeDate gleich ProductionDate → 400")
|
||||
void planBatch_sameDate_returns400() throws Exception {
|
||||
String recipeId = createActiveRecipe();
|
||||
var sameDate = LocalDate.of(2026, 3, 1);
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "KILOGRAM", sameDate, sameDate);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_INVALID_DATES"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rezept nicht gefunden → 400")
|
||||
void planBatch_recipeNotFound_returns400() throws Exception {
|
||||
var request = new PlanBatchRequest(
|
||||
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rezept nicht ACTIVE (DRAFT) → 409")
|
||||
void planBatch_recipeNotActive_returns409() throws Exception {
|
||||
String recipeId = createDraftRecipe();
|
||||
|
||||
var request = new PlanBatchRequest(
|
||||
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_RECIPE_NOT_ACTIVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recipeId leer → 400 (Bean Validation)")
|
||||
void planBatch_blankRecipeId_returns400() throws Exception {
|
||||
var request = new PlanBatchRequest(
|
||||
"", "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plannedQuantity leer → 400 (Bean Validation)")
|
||||
void planBatch_blankQuantity_returns400() throws Exception {
|
||||
var request = new PlanBatchRequest(
|
||||
UUID.randomUUID().toString(), "", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Authorization")
|
||||
class AuthTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne BATCH_WRITE → 403")
|
||||
void planBatch_withViewerToken_returns403() throws Exception {
|
||||
var request = new PlanBatchRequest(
|
||||
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ohne Token → 401")
|
||||
void planBatch_withoutToken_returns401() throws Exception {
|
||||
var request = new PlanBatchRequest(
|
||||
UUID.randomUUID().toString(), "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
|
||||
|
||||
mockMvc.perform(post("/api/production/batches")
|
||||
.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"
|
||||
}
|
||||
""".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