1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 12:29:36 +01:00

feat(production): Charge abschließen (CompleteBatch)

Batch.complete() mit Ist-Menge, Ausschuss und Bemerkungen.
Invarianten: nur IN_PRODUCTION→COMPLETED, mind. eine Consumption,
ActualQuantity > 0, Waste >= 0. Full Vertical Slice mit Domain Event
Stub (BatchCompleted), REST POST /api/batches/{id}/complete und
Liquibase-Migration für die neuen Spalten.
This commit is contained in:
Sebastian Frick 2026-02-23 13:35:30 +01:00
parent f63790c058
commit a08e4194ab
23 changed files with 1138 additions and 9 deletions

View file

@ -0,0 +1,63 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CompleteBatchCommand;
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 CompleteBatch {
private final BatchRepository batchRepository;
private final AuthorizationPort authorizationPort;
public CompleteBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
this.batchRepository = batchRepository;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, Batch> execute(CompleteBatchCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_COMPLETE)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to complete batches"));
}
var batchId = BatchId.of(cmd.batchId());
Batch batch;
switch (batchRepository.findById(batchId)) {
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.BatchNotFound(batchId));
}
batch = opt.get();
}
}
var draft = new CompleteBatchDraft(
cmd.actualQuantity(),
cmd.actualQuantityUnit(),
cmd.waste(),
cmd.wasteUnit(),
cmd.remarks()
);
switch (batch.complete(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
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);
}
}

View file

@ -0,0 +1,10 @@
package de.effigenix.application.production.command;
public record CompleteBatchCommand(
String batchId,
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks
) {}

View file

@ -25,6 +25,10 @@ import java.util.List;
* 7. recordConsumption() only allowed in IN_PRODUCTION status * 7. recordConsumption() only allowed in IN_PRODUCTION status
* 8. No duplicate inputBatchId within consumptions * 8. No duplicate inputBatchId within consumptions
* 9. Consumption quantity must be positive * 9. Consumption quantity must be positive
* 10. complete() only allowed from IN_PRODUCTION status
* 11. At least one Consumption required before completion
* 12. ActualQuantity must be positive
* 13. Waste must be zero or positive
*/ */
public class Batch { public class Batch {
@ -33,10 +37,14 @@ public class Batch {
private final RecipeId recipeId; private final RecipeId recipeId;
private BatchStatus status; private BatchStatus status;
private final Quantity plannedQuantity; private final Quantity plannedQuantity;
private Quantity actualQuantity;
private Quantity waste;
private String remarks;
private final LocalDate productionDate; private final LocalDate productionDate;
private final LocalDate bestBeforeDate; private final LocalDate bestBeforeDate;
private final OffsetDateTime createdAt; private final OffsetDateTime createdAt;
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
private OffsetDateTime completedAt;
private final long version; private final long version;
private final List<Consumption> consumptions; private final List<Consumption> consumptions;
@ -46,10 +54,14 @@ public class Batch {
RecipeId recipeId, RecipeId recipeId,
BatchStatus status, BatchStatus status,
Quantity plannedQuantity, Quantity plannedQuantity,
Quantity actualQuantity,
Quantity waste,
String remarks,
LocalDate productionDate, LocalDate productionDate,
LocalDate bestBeforeDate, LocalDate bestBeforeDate,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
OffsetDateTime completedAt,
long version, long version,
List<Consumption> consumptions List<Consumption> consumptions
) { ) {
@ -58,10 +70,14 @@ public class Batch {
this.recipeId = recipeId; this.recipeId = recipeId;
this.status = status; this.status = status;
this.plannedQuantity = plannedQuantity; this.plannedQuantity = plannedQuantity;
this.actualQuantity = actualQuantity;
this.waste = waste;
this.remarks = remarks;
this.productionDate = productionDate; this.productionDate = productionDate;
this.bestBeforeDate = bestBeforeDate; this.bestBeforeDate = bestBeforeDate;
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
this.completedAt = completedAt;
this.version = version; this.version = version;
this.consumptions = consumptions; this.consumptions = consumptions;
} }
@ -109,10 +125,14 @@ public class Batch {
RecipeId.of(draft.recipeId()), RecipeId.of(draft.recipeId()),
BatchStatus.PLANNED, BatchStatus.PLANNED,
plannedQuantity, plannedQuantity,
null,
null,
null,
draft.productionDate(), draft.productionDate(),
draft.bestBeforeDate(), draft.bestBeforeDate(),
now, now,
now, now,
null,
0L, 0L,
new ArrayList<>() new ArrayList<>()
)); ));
@ -127,6 +147,74 @@ public class Batch {
return Result.success(null); return Result.success(null);
} }
public Result<BatchError, Void> complete(CompleteBatchDraft draft) {
if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.InvalidStatusTransition(status, BatchStatus.COMPLETED));
}
if (consumptions.isEmpty()) {
return Result.failure(new BatchError.MissingConsumptions(id));
}
Quantity actualQty;
switch (parseQuantity(draft.actualQuantity(), draft.actualQuantityUnit(), BatchError.InvalidActualQuantity::new)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var qty) -> actualQty = qty;
}
Quantity wasteQty;
switch (parseWasteQuantity(draft.waste(), draft.wasteUnit())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var qty) -> wasteQty = qty;
}
this.actualQuantity = actualQty;
this.waste = wasteQty;
this.remarks = draft.remarks();
this.status = BatchStatus.COMPLETED;
var now = OffsetDateTime.now(ZoneOffset.UTC);
this.completedAt = now;
this.updatedAt = now;
return Result.success(null);
}
private Result<BatchError, Quantity> parseQuantity(String amountStr, String unitStr,
java.util.function.Function<String, BatchError> errorFactory) {
try {
var amount = new BigDecimal(amountStr);
var uom = UnitOfMeasure.valueOf(unitStr);
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> { return Result.failure(errorFactory.apply(err.toString())); }
case Result.Success(var qty) -> { return Result.success(qty); }
}
} catch (NumberFormatException e) {
return Result.failure(errorFactory.apply("Invalid amount format: " + amountStr));
} catch (IllegalArgumentException e) {
return Result.failure(errorFactory.apply("Invalid unit: " + unitStr));
}
}
private Result<BatchError, Quantity> parseWasteQuantity(String amountStr, String unitStr) {
try {
var amount = new BigDecimal(amountStr);
if (amount.compareTo(BigDecimal.ZERO) < 0) {
return Result.failure(new BatchError.InvalidWaste("Waste must be zero or positive"));
}
var uom = UnitOfMeasure.valueOf(unitStr);
if (amount.compareTo(BigDecimal.ZERO) == 0) {
return Result.success(Quantity.reconstitute(amount, uom));
}
switch (Quantity.of(amount, uom)) {
case Result.Failure(var err) -> { return Result.failure(new BatchError.InvalidWaste(err.toString())); }
case Result.Success(var qty) -> { return Result.success(qty); }
}
} catch (NumberFormatException e) {
return Result.failure(new BatchError.InvalidWaste("Invalid amount format: " + amountStr));
} catch (IllegalArgumentException e) {
return Result.failure(new BatchError.InvalidWaste("Invalid unit: " + unitStr));
}
}
public Result<BatchError, Consumption> recordConsumption(ConsumptionDraft draft) { public Result<BatchError, Consumption> recordConsumption(ConsumptionDraft draft) {
if (status != BatchStatus.IN_PRODUCTION) { if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.NotInProduction(id)); return Result.failure(new BatchError.NotInProduction(id));
@ -155,15 +243,20 @@ public class Batch {
RecipeId recipeId, RecipeId recipeId,
BatchStatus status, BatchStatus status,
Quantity plannedQuantity, Quantity plannedQuantity,
Quantity actualQuantity,
Quantity waste,
String remarks,
LocalDate productionDate, LocalDate productionDate,
LocalDate bestBeforeDate, LocalDate bestBeforeDate,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
OffsetDateTime completedAt,
long version, long version,
List<Consumption> consumptions List<Consumption> consumptions
) { ) {
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate, return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity,
bestBeforeDate, createdAt, updatedAt, version, new ArrayList<>(consumptions)); waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt,
completedAt, version, new ArrayList<>(consumptions));
} }
public BatchId id() { return id; } public BatchId id() { return id; }
@ -171,10 +264,14 @@ public class Batch {
public RecipeId recipeId() { return recipeId; } public RecipeId recipeId() { return recipeId; }
public BatchStatus status() { return status; } public BatchStatus status() { return status; }
public Quantity plannedQuantity() { return plannedQuantity; } public Quantity plannedQuantity() { return plannedQuantity; }
public Quantity actualQuantity() { return actualQuantity; }
public Quantity waste() { return waste; }
public String remarks() { return remarks; }
public LocalDate productionDate() { return productionDate; } public LocalDate productionDate() { return productionDate; }
public LocalDate bestBeforeDate() { return bestBeforeDate; } public LocalDate bestBeforeDate() { return bestBeforeDate; }
public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime createdAt() { return createdAt; }
public OffsetDateTime updatedAt() { return updatedAt; } public OffsetDateTime updatedAt() { return updatedAt; }
public OffsetDateTime completedAt() { return completedAt; }
public long version() { return version; } public long version() { return version; }
public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); } public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); }
} }

View file

@ -54,6 +54,21 @@ public sealed interface BatchError {
@Override public String message() { return "Invalid consumption quantity: " + reason; } @Override public String message() { return "Invalid consumption quantity: " + reason; }
} }
record MissingConsumptions(BatchId id) implements BatchError {
@Override public String code() { return "BATCH_MISSING_CONSUMPTIONS"; }
@Override public String message() { return "Batch '" + id.value() + "' requires at least one consumption before completion"; }
}
record InvalidActualQuantity(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_ACTUAL_QUANTITY"; }
@Override public String message() { return "Invalid actual quantity: " + reason; }
}
record InvalidWaste(String reason) implements BatchError {
@Override public String code() { return "BATCH_INVALID_WASTE"; }
@Override public String message() { return "Invalid waste: " + reason; }
}
record Unauthorized(String message) implements BatchError { record Unauthorized(String message) implements BatchError {
@Override public String code() { return "UNAUTHORIZED"; } @Override public String code() { return "UNAUTHORIZED"; }
} }

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.production;
public record CompleteBatchDraft(
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks
) {}

View file

@ -0,0 +1,17 @@
package de.effigenix.domain.production.event;
import de.effigenix.domain.production.BatchId;
import de.effigenix.shared.common.Quantity;
import java.time.OffsetDateTime;
/**
* Stub wird derzeit nicht publiziert.
* Vorgesehen für spätere Event-Infrastruktur (Inventory stock-in, Audit, Tracing).
*/
public record BatchCompleted(
BatchId batchId,
Quantity actualQuantity,
Quantity waste,
OffsetDateTime completedAt
) {}

View file

@ -4,6 +4,7 @@ import de.effigenix.application.production.ActivateRecipe;
import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient; import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch; import de.effigenix.application.production.GetBatch;
@ -108,4 +109,9 @@ public class ProductionUseCaseConfiguration {
public RecordConsumption recordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort) { public RecordConsumption recordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new RecordConsumption(batchRepository, authorizationPort); return new RecordConsumption(batchRepository, authorizationPort);
} }
@Bean
public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new CompleteBatch(batchRepository, authorizationPort);
}
} }

View file

@ -41,12 +41,30 @@ public class BatchEntity {
@Column(name = "best_before_date", nullable = false) @Column(name = "best_before_date", nullable = false)
private LocalDate bestBeforeDate; private LocalDate bestBeforeDate;
@Column(name = "actual_quantity_amount", precision = 19, scale = 6)
private BigDecimal actualQuantityAmount;
@Column(name = "actual_quantity_unit", length = 10)
private String actualQuantityUnit;
@Column(name = "waste_amount", precision = 19, scale = 6)
private BigDecimal wasteAmount;
@Column(name = "waste_unit", length = 10)
private String wasteUnit;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
@Column(name = "completed_at")
private OffsetDateTime completedAt;
@OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ConsumptionEntity> consumptions = new ArrayList<>(); private List<ConsumptionEntity> consumptions = new ArrayList<>();
@ -89,6 +107,19 @@ public class BatchEntity {
public OffsetDateTime getUpdatedAt() { return updatedAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; }
public List<ConsumptionEntity> getConsumptions() { return consumptions; } public List<ConsumptionEntity> getConsumptions() { return consumptions; }
public BigDecimal getActualQuantityAmount() { return actualQuantityAmount; }
public String getActualQuantityUnit() { return actualQuantityUnit; }
public BigDecimal getWasteAmount() { return wasteAmount; }
public String getWasteUnit() { return wasteUnit; }
public String getRemarks() { return remarks; }
public OffsetDateTime getCompletedAt() { return completedAt; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setActualQuantityAmount(BigDecimal actualQuantityAmount) { this.actualQuantityAmount = actualQuantityAmount; }
public void setActualQuantityUnit(String actualQuantityUnit) { this.actualQuantityUnit = actualQuantityUnit; }
public void setWasteAmount(BigDecimal wasteAmount) { this.wasteAmount = wasteAmount; }
public void setWasteUnit(String wasteUnit) { this.wasteUnit = wasteUnit; }
public void setRemarks(String remarks) { this.remarks = remarks; }
public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; }
} }

View file

@ -29,6 +29,17 @@ public class BatchMapper {
batch.updatedAt() batch.updatedAt()
); );
if (batch.actualQuantity() != null) {
entity.setActualQuantityAmount(batch.actualQuantity().amount());
entity.setActualQuantityUnit(batch.actualQuantity().uom().name());
}
if (batch.waste() != null) {
entity.setWasteAmount(batch.waste().amount());
entity.setWasteUnit(batch.waste().uom().name());
}
entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt());
for (Consumption c : batch.consumptions()) { for (Consumption c : batch.consumptions()) {
entity.getConsumptions().add(toConsumptionEntity(c, entity)); entity.getConsumptions().add(toConsumptionEntity(c, entity));
} }
@ -40,6 +51,23 @@ public class BatchMapper {
entity.setStatus(batch.status().name()); entity.setStatus(batch.status().name());
entity.setUpdatedAt(batch.updatedAt()); entity.setUpdatedAt(batch.updatedAt());
if (batch.actualQuantity() != null) {
entity.setActualQuantityAmount(batch.actualQuantity().amount());
entity.setActualQuantityUnit(batch.actualQuantity().uom().name());
} else {
entity.setActualQuantityAmount(null);
entity.setActualQuantityUnit(null);
}
if (batch.waste() != null) {
entity.setWasteAmount(batch.waste().amount());
entity.setWasteUnit(batch.waste().uom().name());
} else {
entity.setWasteAmount(null);
entity.setWasteUnit(null);
}
entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt());
Set<String> existingIds = entity.getConsumptions().stream() Set<String> existingIds = entity.getConsumptions().stream()
.map(ConsumptionEntity::getId) .map(ConsumptionEntity::getId)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
@ -65,6 +93,13 @@ public class BatchMapper {
)) ))
.toList(); .toList();
Quantity actualQuantity = entity.getActualQuantityAmount() != null
? Quantity.reconstitute(entity.getActualQuantityAmount(), UnitOfMeasure.valueOf(entity.getActualQuantityUnit()))
: null;
Quantity waste = entity.getWasteAmount() != null
? Quantity.reconstitute(entity.getWasteAmount(), UnitOfMeasure.valueOf(entity.getWasteUnit()))
: null;
return Batch.reconstitute( return Batch.reconstitute(
BatchId.of(entity.getId()), BatchId.of(entity.getId()),
new BatchNumber(entity.getBatchNumber()), new BatchNumber(entity.getBatchNumber()),
@ -74,10 +109,14 @@ public class BatchMapper {
entity.getPlannedQuantityAmount(), entity.getPlannedQuantityAmount(),
UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()) UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit())
), ),
actualQuantity,
waste,
entity.getRemarks(),
entity.getProductionDate(), entity.getProductionDate(),
entity.getBestBeforeDate(), entity.getBestBeforeDate(),
entity.getCreatedAt(), entity.getCreatedAt(),
entity.getUpdatedAt(), entity.getUpdatedAt(),
entity.getCompletedAt(),
entity.getVersion(), entity.getVersion(),
consumptions consumptions
); );

View file

@ -1,11 +1,13 @@
package de.effigenix.infrastructure.production.web.controller; package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch; import de.effigenix.application.production.GetBatch;
import de.effigenix.application.production.ListBatches; import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecordConsumption; import de.effigenix.application.production.RecordConsumption;
import de.effigenix.application.production.StartBatch; import de.effigenix.application.production.StartBatch;
import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.application.production.command.RecordConsumptionCommand;
import de.effigenix.application.production.command.StartBatchCommand; import de.effigenix.application.production.command.StartBatchCommand;
@ -16,6 +18,7 @@ import de.effigenix.domain.production.BatchStatus;
import de.effigenix.infrastructure.production.web.dto.BatchResponse; import de.effigenix.infrastructure.production.web.dto.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse; import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse;
import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse; import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest; import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
@ -48,16 +51,18 @@ public class BatchController {
private final FindBatchByNumber findBatchByNumber; private final FindBatchByNumber findBatchByNumber;
private final StartBatch startBatch; private final StartBatch startBatch;
private final RecordConsumption recordConsumption; private final RecordConsumption recordConsumption;
private final CompleteBatch completeBatch;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch, FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption) { RecordConsumption recordConsumption, CompleteBatch completeBatch) {
this.planBatch = planBatch; this.planBatch = planBatch;
this.getBatch = getBatch; this.getBatch = getBatch;
this.listBatches = listBatches; this.listBatches = listBatches;
this.findBatchByNumber = findBatchByNumber; this.findBatchByNumber = findBatchByNumber;
this.startBatch = startBatch; this.startBatch = startBatch;
this.recordConsumption = recordConsumption; this.recordConsumption = recordConsumption;
this.completeBatch = completeBatch;
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@ -206,6 +211,33 @@ public class BatchController {
.body(ConsumptionResponse.from(result.unsafeGetValue())); .body(ConsumptionResponse.from(result.unsafeGetValue()));
} }
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('BATCH_COMPLETE')")
public ResponseEntity<BatchResponse> completeBatch(
@PathVariable("id") String id,
@Valid @RequestBody CompleteBatchRequest request,
Authentication authentication
) {
logger.info("Completing batch: {} by actor: {}", id, authentication.getName());
var cmd = new CompleteBatchCommand(
id,
request.actualQuantity(),
request.actualQuantityUnit(),
request.waste(),
request.wasteUnit(),
request.remarks()
);
var result = completeBatch.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
}
private static String filterType(String status, LocalDate productionDate, String articleId) { private static String filterType(String status, LocalDate productionDate, String articleId) {
int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0); int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0);
if (count > 1) return "ambiguous"; if (count > 1) return "ambiguous";

View file

@ -13,11 +13,17 @@ public record BatchResponse(
String status, String status,
String plannedQuantity, String plannedQuantity,
String plannedQuantityUnit, String plannedQuantityUnit,
String actualQuantity,
String actualQuantityUnit,
String waste,
String wasteUnit,
String remarks,
LocalDate productionDate, LocalDate productionDate,
LocalDate bestBeforeDate, LocalDate bestBeforeDate,
List<ConsumptionResponse> consumptions, List<ConsumptionResponse> consumptions,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt OffsetDateTime updatedAt,
OffsetDateTime completedAt
) { ) {
public static BatchResponse from(Batch batch) { public static BatchResponse from(Batch batch) {
var consumptions = batch.consumptions().stream() var consumptions = batch.consumptions().stream()
@ -31,11 +37,17 @@ public record BatchResponse(
batch.status().name(), batch.status().name(),
batch.plannedQuantity().amount().toPlainString(), batch.plannedQuantity().amount().toPlainString(),
batch.plannedQuantity().uom().name(), batch.plannedQuantity().uom().name(),
batch.actualQuantity() != null ? batch.actualQuantity().amount().toPlainString() : null,
batch.actualQuantity() != null ? batch.actualQuantity().uom().name() : null,
batch.waste() != null ? batch.waste().amount().toPlainString() : null,
batch.waste() != null ? batch.waste().uom().name() : null,
batch.remarks(),
batch.productionDate(), batch.productionDate(),
batch.bestBeforeDate(), batch.bestBeforeDate(),
consumptions, consumptions,
batch.createdAt(), batch.createdAt(),
batch.updatedAt() batch.updatedAt(),
batch.completedAt()
); );
} }
} }

View file

@ -0,0 +1,19 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CompleteBatchRequest(
@NotBlank(message = "actualQuantity is required")
String actualQuantity,
@NotBlank(message = "actualQuantityUnit is required")
String actualQuantityUnit,
@NotBlank(message = "waste is required")
String waste,
@NotBlank(message = "wasteUnit is required")
String wasteUnit,
String remarks
) {}

View file

@ -37,6 +37,9 @@ public final class ProductionErrorHttpStatusMapper {
case BatchError.NotInProduction e -> 409; case BatchError.NotInProduction e -> 409;
case BatchError.DuplicateInputBatch e -> 409; case BatchError.DuplicateInputBatch e -> 409;
case BatchError.InvalidConsumptionQuantity e -> 400; case BatchError.InvalidConsumptionQuantity e -> 400;
case BatchError.MissingConsumptions e -> 409;
case BatchError.InvalidActualQuantity e -> 400;
case BatchError.InvalidWaste e -> 400;
case BatchError.ValidationFailure e -> 400; case BatchError.ValidationFailure e -> 400;
case BatchError.Unauthorized e -> 403; case BatchError.Unauthorized e -> 403;
case BatchError.RepositoryFailure e -> 500; case BatchError.RepositoryFailure e -> 500;

View file

@ -0,0 +1,19 @@
<?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="021-add-completion-fields-to-batches" author="effigenix">
<addColumn tableName="batches">
<column name="actual_quantity_amount" type="DECIMAL(19,6)"/>
<column name="actual_quantity_unit" type="VARCHAR(10)"/>
<column name="waste_amount" type="DECIMAL(19,6)"/>
<column name="waste_unit" type="VARCHAR(10)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="completed_at" type="TIMESTAMPTZ"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -25,5 +25,6 @@
<include file="db/changelog/changes/018-add-article-id-to-recipes.xml"/> <include file="db/changelog/changes/018-add-article-id-to-recipes.xml"/>
<include file="db/changelog/changes/019-create-batch-consumptions-table.xml"/> <include file="db/changelog/changes/019-create-batch-consumptions-table.xml"/>
<include file="db/changelog/changes/020-add-version-to-batches.xml"/> <include file="db/changelog/changes/020-add-version-to-batches.xml"/>
<include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,231 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("CompleteBatch Use Case")
class CompleteBatchTest {
@Mock private BatchRepository batchRepository;
@Mock private AuthorizationPort authPort;
private CompleteBatch completeBatch;
private ActorId performedBy;
@BeforeEach
void setUp() {
completeBatch = new CompleteBatch(batchRepository, authPort);
performedBy = ActorId.of("admin-user");
}
private Batch inProductionBatchWithConsumption(String id) {
var batch = Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of()
);
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
return batch;
}
private Batch inProductionBatchWithoutConsumption(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of()
);
}
private CompleteBatchCommand validCommand(String batchId) {
return new CompleteBatchCommand(batchId, "95", "KILOGRAM", "5", "KILOGRAM", "Test remarks");
}
@Test
@DisplayName("should complete batch with actual quantity and waste")
void should_CompleteBatch_When_Valid() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatchWithConsumption("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
when(batchRepository.save(any())).thenReturn(Result.success(null));
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.COMPLETED);
assertThat(result.unsafeGetValue().actualQuantity().amount()).isEqualByComparingTo(new BigDecimal("95"));
assertThat(result.unsafeGetValue().waste().amount()).isEqualByComparingTo(new BigDecimal("5"));
assertThat(result.unsafeGetValue().remarks()).isEqualTo("Test remarks");
verify(batchRepository).save(batch);
}
@Test
@DisplayName("should fail when batch not found")
void should_Fail_When_BatchNotFound() {
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(any())).thenReturn(Result.success(Optional.empty()));
var result = completeBatch.execute(validCommand("nonexistent"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when no consumptions recorded")
void should_Fail_When_NoConsumptions() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatchWithoutConsumption("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.MissingConsumptions.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when actualQuantity is zero")
void should_Fail_When_ActualQuantityZero() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatchWithConsumption("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var cmd = new CompleteBatchCommand("batch-1", "0", "KILOGRAM", "5", "KILOGRAM", null);
var result = completeBatch.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when batch is PLANNED (not in production)")
void should_Fail_When_BatchIsPlanned() {
var batchId = BatchId.of("batch-1");
var batch = Batch.reconstitute(
batchId,
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of()
);
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail when waste is negative")
void should_Fail_When_NegativeWaste() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatchWithConsumption("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
var cmd = new CompleteBatchCommand("batch-1", "95", "KILOGRAM", "-1", "KILOGRAM", null);
var result = completeBatch.execute(cmd, performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
verify(batchRepository, never()).save(any());
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(false);
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(batchRepository, never()).findById(any());
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during find")
void should_FailWithRepositoryFailure_When_FindFails() {
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure on repository error during save")
void should_FailWithRepositoryFailure_When_SaveFails() {
var batchId = BatchId.of("batch-1");
var batch = inProductionBatchWithConsumption("batch-1");
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);
when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch)));
when(batchRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("write error")));
var result = completeBatch.execute(validCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
}

View file

@ -49,10 +49,12 @@ class FindBatchByNumberTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.PLANNED, BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -47,10 +47,12 @@ class GetBatchTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.PLANNED, BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -51,10 +51,12 @@ class ListBatchesTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
status, status,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, PRODUCTION_DATE,
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -48,10 +48,12 @@ class RecordConsumptionTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION, BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }
@ -63,10 +65,12 @@ class RecordConsumptionTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.PLANNED, BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -48,10 +48,12 @@ class StartBatchTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.PLANNED, BatchStatus.PLANNED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }
@ -63,10 +65,12 @@ class StartBatchTest {
RecipeId.of("recipe-1"), RecipeId.of("recipe-1"),
BatchStatus.IN_PRODUCTION, BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -208,8 +208,10 @@ class BatchTest {
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"), BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.IN_PRODUCTION, BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
@ -229,8 +231,10 @@ class BatchTest {
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"), BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED, BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
@ -247,8 +251,10 @@ class BatchTest {
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"), BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.CANCELLED, BatchStatus.CANCELLED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
@ -311,8 +317,10 @@ class BatchTest {
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"), BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED, BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"); var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
@ -362,6 +370,257 @@ class BatchTest {
} }
} }
@Nested
@DisplayName("complete()")
class Complete {
private Batch inProductionBatchWithConsumption() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
batch.startProduction();
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"));
return batch;
}
@Test
@DisplayName("should complete batch with actual quantity, waste, and remarks")
void should_Complete_When_ValidDraft() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", "Alles ok");
var result = batch.complete(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.status()).isEqualTo(BatchStatus.COMPLETED);
assertThat(batch.actualQuantity().amount()).isEqualByComparingTo(new BigDecimal("95"));
assertThat(batch.actualQuantity().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(batch.waste().amount()).isEqualByComparingTo(new BigDecimal("5"));
assertThat(batch.waste().uom()).isEqualTo(UnitOfMeasure.KILOGRAM);
assertThat(batch.remarks()).isEqualTo("Alles ok");
assertThat(batch.completedAt()).isNotNull();
}
@Test
@DisplayName("should complete batch with zero waste")
void should_Complete_When_ZeroWaste() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("100", "KILOGRAM", "0", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.status()).isEqualTo(BatchStatus.COMPLETED);
assertThat(batch.waste().amount()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("should complete batch without remarks")
void should_Complete_When_NoRemarks() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isSuccess()).isTrue();
assertThat(batch.remarks()).isNull();
}
@Test
@DisplayName("should fail when not IN_PRODUCTION (PLANNED)")
void should_Fail_When_Planned() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when already COMPLETED")
void should_Fail_When_AlreadyCompleted() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of()
);
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when no consumptions recorded")
void should_Fail_When_NoConsumptions() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
batch.startProduction();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.MissingConsumptions.class);
}
@Test
@DisplayName("should fail when actualQuantity is zero")
void should_Fail_When_ActualQuantityZero() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("0", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
}
@Test
@DisplayName("should fail when actualQuantity is negative")
void should_Fail_When_ActualQuantityNegative() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("-10", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
}
@Test
@DisplayName("should fail when waste is negative")
void should_Fail_When_WasteNegative() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "-1", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
}
@Test
@DisplayName("should fail when CANCELLED")
void should_Fail_When_Cancelled() {
var batch = Batch.reconstitute(
BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"),
BatchStatus.CANCELLED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of()
);
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class);
var err = (BatchError.InvalidStatusTransition) result.unsafeGetError();
assertThat(err.current()).isEqualTo(BatchStatus.CANCELLED);
assertThat(err.target()).isEqualTo(BatchStatus.COMPLETED);
}
@Test
@DisplayName("should fail when waste format is invalid")
void should_Fail_When_InvalidWasteFormat() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "abc", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
}
@Test
@DisplayName("should fail when waste unit is invalid")
void should_Fail_When_InvalidWasteUnit() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "INVALID", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidWaste.class);
}
@Test
@DisplayName("should not mutate state on validation failure")
void should_NotMutateState_When_ValidationFails() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("0", "KILOGRAM", "5", "KILOGRAM", null);
batch.complete(draft);
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
assertThat(batch.actualQuantity()).isNull();
assertThat(batch.waste()).isNull();
assertThat(batch.remarks()).isNull();
assertThat(batch.completedAt()).isNull();
}
@Test
@DisplayName("should not mutate state when missing consumptions")
void should_NotMutateState_When_MissingConsumptions() {
var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue();
batch.startProduction();
var beforeUpdate = batch.updatedAt();
batch.complete(new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null));
assertThat(batch.status()).isEqualTo(BatchStatus.IN_PRODUCTION);
assertThat(batch.actualQuantity()).isNull();
assertThat(batch.updatedAt()).isEqualTo(beforeUpdate);
}
@Test
@DisplayName("should fail when actualQuantity format is invalid")
void should_Fail_When_InvalidActualQuantityFormat() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("abc", "KILOGRAM", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
}
@Test
@DisplayName("should fail when actualQuantity unit is invalid")
void should_Fail_When_InvalidActualQuantityUnit() {
var batch = inProductionBatchWithConsumption();
var draft = new CompleteBatchDraft("95", "INVALID", "5", "KILOGRAM", null);
var result = batch.complete(draft);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidActualQuantity.class);
}
@Test
@DisplayName("should update updatedAt and set completedAt on completion")
void should_UpdateTimestamps_When_Completing() {
var batch = inProductionBatchWithConsumption();
var beforeComplete = batch.updatedAt();
batch.complete(new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null));
assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeComplete);
assertThat(batch.completedAt()).isNotNull();
assertThat(batch.completedAt()).isEqualTo(batch.updatedAt());
}
}
@Nested @Nested
@DisplayName("reconstitute()") @DisplayName("reconstitute()")
class Reconstitute { class Reconstitute {
@ -375,10 +634,12 @@ class BatchTest {
RecipeId.of("recipe-123"), RecipeId.of("recipe-123"),
BatchStatus.IN_PRODUCTION, BatchStatus.IN_PRODUCTION,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
PRODUCTION_DATE, PRODUCTION_DATE,
BEST_BEFORE_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null,
0L, List.of() 0L, List.of()
); );

View file

@ -278,10 +278,256 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
} }
} }
@Nested
@DisplayName("POST /api/production/batches/{id}/complete Charge abschließen")
class CompleteBatchEndpoint {
private String completeToken;
@BeforeEach
void setUpCompleteToken() {
completeToken = generateToken(UUID.randomUUID().toString(), "complete.admin",
"BATCH_WRITE,BATCH_READ,BATCH_COMPLETE,RECIPE_WRITE,RECIPE_READ");
}
private String createInProductionBatchWithConsumption() throws Exception {
String recipeId = createActiveRecipeWith(completeToken);
// Plan batch
var planRequest = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
// Start production
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + completeToken))
.andExpect(status().isOk());
// Record consumption
String consumptionJson = """
{"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "5.0", "quantityUnit": "KILOGRAM"}
""".formatted(UUID.randomUUID().toString(), UUID.randomUUID().toString());
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(consumptionJson))
.andExpect(status().isCreated());
return batchId;
}
@Test
@DisplayName("Charge abschließen mit Ist-Menge und Ausschuss → 200, Status COMPLETED")
void completeBatch_withValidData_returns200() throws Exception {
String batchId = createInProductionBatchWithConsumption();
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM", "remarks": "Alles ok"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("COMPLETED"))
.andExpect(jsonPath("$.actualQuantity").value("95.000000"))
.andExpect(jsonPath("$.actualQuantityUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.waste").value("5.000000"))
.andExpect(jsonPath("$.wasteUnit").value("KILOGRAM"))
.andExpect(jsonPath("$.remarks").value("Alles ok"))
.andExpect(jsonPath("$.completedAt").isNotEmpty());
}
@Test
@DisplayName("Ohne Consumptions → 409 (MissingConsumptions)")
void completeBatch_withoutConsumptions_returns400() throws Exception {
String recipeId = createActiveRecipeWith(completeToken);
// Plan + Start, aber keine Consumption
var planRequest = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + completeToken))
.andExpect(status().isOk());
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_MISSING_CONSUMPTIONS"));
}
@Test
@DisplayName("ActualQuantity 0 → 400")
void completeBatch_zeroActualQuantity_returns400() throws Exception {
String batchId = createInProductionBatchWithConsumption();
String completeJson = """
{"actualQuantity": "0", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_ACTUAL_QUANTITY"));
}
@Test
@DisplayName("Negativer Waste → 400")
void completeBatch_negativeWaste_returns400() throws Exception {
String batchId = createInProductionBatchWithConsumption();
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "-1", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_WASTE"));
}
@Test
@DisplayName("PLANNED Batch (nicht gestartet) → 409")
void completeBatch_plannedBatch_returns409() throws Exception {
String recipeId = createActiveRecipeWith(completeToken);
var planRequest = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Bereits COMPLETED → 409")
void completeBatch_alreadyCompleted_returns409() throws Exception {
String batchId = createInProductionBatchWithConsumption();
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
// First complete success
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isOk());
// Second complete conflict
mockMvc.perform(post("/api/production/batches/{id}/complete", batchId)
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Batch nicht gefunden → 404")
void completeBatch_notFound_returns404() throws Exception {
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Bean Validation: actualQuantity leer → 400")
void completeBatch_blankActualQuantity_returns400() throws Exception {
String completeJson = """
{"actualQuantity": "", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + completeToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("Ohne Token → 401")
void completeBatch_withoutToken_returns401() throws Exception {
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne BATCH_COMPLETE → 403")
void completeBatch_withoutPermission_returns403() throws Exception {
String completeJson = """
{"actualQuantity": "95", "actualQuantityUnit": "KILOGRAM", "waste": "5", "wasteUnit": "KILOGRAM"}
""";
mockMvc.perform(post("/api/production/batches/{id}/complete", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken)
.contentType(MediaType.APPLICATION_JSON)
.content(completeJson))
.andExpect(status().isForbidden());
}
}
// ==================== Hilfsmethoden ==================== // ==================== Hilfsmethoden ====================
private String createActiveRecipe() throws Exception { private String createActiveRecipe() throws Exception {
String recipeId = createDraftRecipe(); return createActiveRecipeWith(adminToken);
}
private String createActiveRecipeWith(String token) throws Exception {
String recipeId = createDraftRecipeWith(token);
// Add ingredient (required for activation) // Add ingredient (required for activation)
String ingredientJson = """ String ingredientJson = """
@ -289,20 +535,24 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
""".formatted(UUID.randomUUID().toString()); """.formatted(UUID.randomUUID().toString());
mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId)
.header("Authorization", "Bearer " + adminToken) .header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(ingredientJson)) .content(ingredientJson))
.andExpect(status().isCreated()); .andExpect(status().isCreated());
// Activate // Activate
mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) mockMvc.perform(post("/api/recipes/{id}/activate", recipeId)
.header("Authorization", "Bearer " + adminToken)) .header("Authorization", "Bearer " + token))
.andExpect(status().isOk()); .andExpect(status().isOk());
return recipeId; return recipeId;
} }
private String createDraftRecipe() throws Exception { private String createDraftRecipe() throws Exception {
return createDraftRecipeWith(adminToken);
}
private String createDraftRecipeWith(String token) throws Exception {
String json = """ String json = """
{ {
"name": "Test-Rezept-%s", "name": "Test-Rezept-%s",
@ -318,7 +568,7 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
""".formatted(UUID.randomUUID().toString().substring(0, 8)); """.formatted(UUID.randomUUID().toString().substring(0, 8));
var result = mockMvc.perform(post("/api/recipes") var result = mockMvc.perform(post("/api/recipes")
.header("Authorization", "Bearer " + adminToken) .header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(json)) .content(json))
.andExpect(status().isCreated()) .andExpect(status().isCreated())