mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:29:35 +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:
parent
f63790c058
commit
a08e4194ab
23 changed files with 1138 additions and 9 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
public record CompleteBatchDraft(
|
||||||
|
String actualQuantity,
|
||||||
|
String actualQuantityUnit,
|
||||||
|
String waste,
|
||||||
|
String wasteUnit,
|
||||||
|
String remarks
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue