From a08e4194ab3aa570398c0be132783c886302e3b7 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Mon, 23 Feb 2026 13:35:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(production):=20Charge=20abschlie=C3=9Fen?= =?UTF-8?q?=20(CompleteBatch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../application/production/CompleteBatch.java | 63 +++++ .../command/CompleteBatchCommand.java | 10 + .../de/effigenix/domain/production/Batch.java | 101 ++++++- .../domain/production/BatchError.java | 15 + .../domain/production/CompleteBatchDraft.java | 9 + .../production/event/BatchCompleted.java | 17 ++ .../ProductionUseCaseConfiguration.java | 6 + .../persistence/entity/BatchEntity.java | 31 +++ .../persistence/mapper/BatchMapper.java | 39 +++ .../web/controller/BatchController.java | 34 ++- .../production/web/dto/BatchResponse.java | 16 +- .../web/dto/CompleteBatchRequest.java | 19 ++ .../ProductionErrorHttpStatusMapper.java | 3 + .../021-add-completion-fields-to-batches.xml | 19 ++ .../db/changelog/db.changelog-master.xml | 1 + .../production/CompleteBatchTest.java | 231 ++++++++++++++++ .../production/FindBatchByNumberTest.java | 2 + .../application/production/GetBatchTest.java | 2 + .../production/ListBatchesTest.java | 2 + .../production/RecordConsumptionTest.java | 4 + .../production/StartBatchTest.java | 4 + .../domain/production/BatchTest.java | 261 ++++++++++++++++++ .../web/BatchControllerIntegrationTest.java | 258 ++++++++++++++++- 23 files changed, 1138 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/production/CompleteBatch.java create mode 100644 backend/src/main/java/de/effigenix/application/production/command/CompleteBatchCommand.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/CompleteBatchDraft.java create mode 100644 backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CompleteBatchRequest.java create mode 100644 backend/src/main/resources/db/changelog/changes/021-add-completion-fields-to-batches.xml create mode 100644 backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java diff --git a/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java b/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java new file mode 100644 index 0000000..d9e5ef6 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/CompleteBatch.java @@ -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 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); + } +} diff --git a/backend/src/main/java/de/effigenix/application/production/command/CompleteBatchCommand.java b/backend/src/main/java/de/effigenix/application/production/command/CompleteBatchCommand.java new file mode 100644 index 0000000..699c273 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/CompleteBatchCommand.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/Batch.java b/backend/src/main/java/de/effigenix/domain/production/Batch.java index 8f099cd..efd7ec0 100644 --- a/backend/src/main/java/de/effigenix/domain/production/Batch.java +++ b/backend/src/main/java/de/effigenix/domain/production/Batch.java @@ -25,6 +25,10 @@ import java.util.List; * 7. recordConsumption() only allowed in IN_PRODUCTION status * 8. No duplicate inputBatchId within consumptions * 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 { @@ -33,10 +37,14 @@ public class Batch { private final RecipeId recipeId; private BatchStatus status; private final Quantity plannedQuantity; + private Quantity actualQuantity; + private Quantity waste; + private String remarks; private final LocalDate productionDate; private final LocalDate bestBeforeDate; private final OffsetDateTime createdAt; private OffsetDateTime updatedAt; + private OffsetDateTime completedAt; private final long version; private final List consumptions; @@ -46,10 +54,14 @@ public class Batch { RecipeId recipeId, BatchStatus status, Quantity plannedQuantity, + Quantity actualQuantity, + Quantity waste, + String remarks, LocalDate productionDate, LocalDate bestBeforeDate, OffsetDateTime createdAt, OffsetDateTime updatedAt, + OffsetDateTime completedAt, long version, List consumptions ) { @@ -58,10 +70,14 @@ public class Batch { this.recipeId = recipeId; this.status = status; this.plannedQuantity = plannedQuantity; + this.actualQuantity = actualQuantity; + this.waste = waste; + this.remarks = remarks; this.productionDate = productionDate; this.bestBeforeDate = bestBeforeDate; this.createdAt = createdAt; this.updatedAt = updatedAt; + this.completedAt = completedAt; this.version = version; this.consumptions = consumptions; } @@ -109,10 +125,14 @@ public class Batch { RecipeId.of(draft.recipeId()), BatchStatus.PLANNED, plannedQuantity, + null, + null, + null, draft.productionDate(), draft.bestBeforeDate(), now, now, + null, 0L, new ArrayList<>() )); @@ -127,6 +147,74 @@ public class Batch { return Result.success(null); } + public Result 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 parseQuantity(String amountStr, String unitStr, + java.util.function.Function 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 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 recordConsumption(ConsumptionDraft draft) { if (status != BatchStatus.IN_PRODUCTION) { return Result.failure(new BatchError.NotInProduction(id)); @@ -155,15 +243,20 @@ public class Batch { RecipeId recipeId, BatchStatus status, Quantity plannedQuantity, + Quantity actualQuantity, + Quantity waste, + String remarks, LocalDate productionDate, LocalDate bestBeforeDate, OffsetDateTime createdAt, OffsetDateTime updatedAt, + OffsetDateTime completedAt, long version, List consumptions ) { - return new Batch(id, batchNumber, recipeId, status, plannedQuantity, productionDate, - bestBeforeDate, createdAt, updatedAt, version, new ArrayList<>(consumptions)); + return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity, + waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt, + completedAt, version, new ArrayList<>(consumptions)); } public BatchId id() { return id; } @@ -171,10 +264,14 @@ public class Batch { public RecipeId recipeId() { return recipeId; } public BatchStatus status() { return status; } 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 bestBeforeDate() { return bestBeforeDate; } public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime updatedAt() { return updatedAt; } + public OffsetDateTime completedAt() { return completedAt; } public long version() { return version; } public List consumptions() { return Collections.unmodifiableList(consumptions); } } diff --git a/backend/src/main/java/de/effigenix/domain/production/BatchError.java b/backend/src/main/java/de/effigenix/domain/production/BatchError.java index ac5333f..060ef84 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchError.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchError.java @@ -54,6 +54,21 @@ public sealed interface BatchError { @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 { @Override public String code() { return "UNAUTHORIZED"; } } diff --git a/backend/src/main/java/de/effigenix/domain/production/CompleteBatchDraft.java b/backend/src/main/java/de/effigenix/domain/production/CompleteBatchDraft.java new file mode 100644 index 0000000..1392d9c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/CompleteBatchDraft.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.production; + +public record CompleteBatchDraft( + String actualQuantity, + String actualQuantityUnit, + String waste, + String wasteUnit, + String remarks +) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java b/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java new file mode 100644 index 0000000..dd53b16 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/event/BatchCompleted.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java index e8ab6f3..b8a4edf 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/ProductionUseCaseConfiguration.java @@ -4,6 +4,7 @@ import de.effigenix.application.production.ActivateRecipe; import de.effigenix.application.production.ArchiveRecipe; import de.effigenix.application.production.AddProductionStep; import de.effigenix.application.production.AddRecipeIngredient; +import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.GetBatch; @@ -108,4 +109,9 @@ public class ProductionUseCaseConfiguration { public RecordConsumption recordConsumption(BatchRepository batchRepository, AuthorizationPort authorizationPort) { return new RecordConsumption(batchRepository, authorizationPort); } + + @Bean + public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + return new CompleteBatch(batchRepository, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java index 6118c81..9ed1099 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/entity/BatchEntity.java @@ -41,12 +41,30 @@ public class BatchEntity { @Column(name = "best_before_date", nullable = false) 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) private OffsetDateTime createdAt; @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + @Column(name = "completed_at") + private OffsetDateTime completedAt; + @OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List consumptions = new ArrayList<>(); @@ -89,6 +107,19 @@ public class BatchEntity { public OffsetDateTime getUpdatedAt() { return updatedAt; } public List 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 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; } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java index 6f8ae94..37c8a65 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/persistence/mapper/BatchMapper.java @@ -29,6 +29,17 @@ public class BatchMapper { 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()) { entity.getConsumptions().add(toConsumptionEntity(c, entity)); } @@ -40,6 +51,23 @@ public class BatchMapper { entity.setStatus(batch.status().name()); 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 existingIds = entity.getConsumptions().stream() .map(ConsumptionEntity::getId) .collect(Collectors.toSet()); @@ -65,6 +93,13 @@ public class BatchMapper { )) .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( BatchId.of(entity.getId()), new BatchNumber(entity.getBatchNumber()), @@ -74,10 +109,14 @@ public class BatchMapper { entity.getPlannedQuantityAmount(), UnitOfMeasure.valueOf(entity.getPlannedQuantityUnit()) ), + actualQuantity, + waste, + entity.getRemarks(), entity.getProductionDate(), entity.getBestBeforeDate(), entity.getCreatedAt(), entity.getUpdatedAt(), + entity.getCompletedAt(), entity.getVersion(), consumptions ); diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java index a57bd94..d5addb5 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/controller/BatchController.java @@ -1,11 +1,13 @@ package de.effigenix.infrastructure.production.web.controller; +import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.GetBatch; import de.effigenix.application.production.ListBatches; import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.RecordConsumption; 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.RecordConsumptionCommand; 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.BatchSummaryResponse; 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.RecordConsumptionRequest; import de.effigenix.shared.security.ActorId; @@ -48,16 +51,18 @@ public class BatchController { private final FindBatchByNumber findBatchByNumber; private final StartBatch startBatch; private final RecordConsumption recordConsumption; + private final CompleteBatch completeBatch; public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, FindBatchByNumber findBatchByNumber, StartBatch startBatch, - RecordConsumption recordConsumption) { + RecordConsumption recordConsumption, CompleteBatch completeBatch) { this.planBatch = planBatch; this.getBatch = getBatch; this.listBatches = listBatches; this.findBatchByNumber = findBatchByNumber; this.startBatch = startBatch; this.recordConsumption = recordConsumption; + this.completeBatch = completeBatch; } @GetMapping("/{id}") @@ -206,6 +211,33 @@ public class BatchController { .body(ConsumptionResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/complete") + @PreAuthorize("hasAuthority('BATCH_COMPLETE')") + public ResponseEntity 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) { int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0); if (count > 1) return "ambiguous"; diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java index 074e98d..cf1c7d2 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/BatchResponse.java @@ -13,11 +13,17 @@ public record BatchResponse( String status, String plannedQuantity, String plannedQuantityUnit, + String actualQuantity, + String actualQuantityUnit, + String waste, + String wasteUnit, + String remarks, LocalDate productionDate, LocalDate bestBeforeDate, List consumptions, OffsetDateTime createdAt, - OffsetDateTime updatedAt + OffsetDateTime updatedAt, + OffsetDateTime completedAt ) { public static BatchResponse from(Batch batch) { var consumptions = batch.consumptions().stream() @@ -31,11 +37,17 @@ public record BatchResponse( batch.status().name(), batch.plannedQuantity().amount().toPlainString(), 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.bestBeforeDate(), consumptions, batch.createdAt(), - batch.updatedAt() + batch.updatedAt(), + batch.completedAt() ); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CompleteBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CompleteBatchRequest.java new file mode 100644 index 0000000..cfba93d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CompleteBatchRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java index 386121d..e5d7ad7 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/exception/ProductionErrorHttpStatusMapper.java @@ -37,6 +37,9 @@ public final class ProductionErrorHttpStatusMapper { case BatchError.NotInProduction e -> 409; case BatchError.DuplicateInputBatch e -> 409; 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.Unauthorized e -> 403; case BatchError.RepositoryFailure e -> 500; diff --git a/backend/src/main/resources/db/changelog/changes/021-add-completion-fields-to-batches.xml b/backend/src/main/resources/db/changelog/changes/021-add-completion-fields-to-batches.xml new file mode 100644 index 0000000..01c0ddc --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/021-add-completion-fields-to-batches.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 36c9993..56e5e4d 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -25,5 +25,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java new file mode 100644 index 0000000..64334e3 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java @@ -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); + } +} diff --git a/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java b/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java index ffae896..5e5484c 100644 --- a/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java +++ b/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java @@ -49,10 +49,12 @@ class FindBatchByNumberTest { 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() ); } diff --git a/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java b/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java index 72ccc8e..e15f5e8 100644 --- a/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java @@ -47,10 +47,12 @@ class GetBatchTest { 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() ); } diff --git a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java index c439bc4..94bccde 100644 --- a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java @@ -51,10 +51,12 @@ class ListBatchesTest { RecipeId.of("recipe-1"), status, Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(), + null, null, null, PRODUCTION_DATE, LocalDate.of(2026, 6, 1), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), + null, 0L, List.of() ); } diff --git a/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java b/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java index 8c84995..da830fa 100644 --- a/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java +++ b/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java @@ -48,10 +48,12 @@ class RecordConsumptionTest { 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() ); } @@ -63,10 +65,12 @@ class RecordConsumptionTest { 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() ); } diff --git a/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java b/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java index d2ba366..c2f343d 100644 --- a/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java @@ -48,10 +48,12 @@ class StartBatchTest { 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() ); } @@ -63,10 +65,12 @@ class StartBatchTest { 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() ); } diff --git a/backend/src/test/java/de/effigenix/domain/production/BatchTest.java b/backend/src/test/java/de/effigenix/domain/production/BatchTest.java index 3153205..753569d 100644 --- a/backend/src/test/java/de/effigenix/domain/production/BatchTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/BatchTest.java @@ -208,8 +208,10 @@ class BatchTest { BatchId.of("b-1"), BATCH_NUMBER, RecipeId.of("r-1"), BatchStatus.IN_PRODUCTION, 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() ); @@ -229,8 +231,10 @@ class BatchTest { 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() ); @@ -247,8 +251,10 @@ class BatchTest { 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() ); @@ -311,8 +317,10 @@ class BatchTest { 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 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 @DisplayName("reconstitute()") class Reconstitute { @@ -375,10 +634,12 @@ class BatchTest { RecipeId.of("recipe-123"), BatchStatus.IN_PRODUCTION, 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() ); diff --git a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java index a5244d8..34e900f 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/production/web/BatchControllerIntegrationTest.java @@ -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 ==================== 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) String ingredientJson = """ @@ -289,20 +535,24 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { """.formatted(UUID.randomUUID().toString()); mockMvc.perform(post("/api/recipes/{id}/ingredients", recipeId) - .header("Authorization", "Bearer " + adminToken) + .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(ingredientJson)) .andExpect(status().isCreated()); // Activate mockMvc.perform(post("/api/recipes/{id}/activate", recipeId) - .header("Authorization", "Bearer " + adminToken)) + .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); return recipeId; } private String createDraftRecipe() throws Exception { + return createDraftRecipeWith(adminToken); + } + + private String createDraftRecipeWith(String token) throws Exception { String json = """ { "name": "Test-Rezept-%s", @@ -318,7 +568,7 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { """.formatted(UUID.randomUUID().toString().substring(0, 8)); var result = mockMvc.perform(post("/api/recipes") - .header("Authorization", "Bearer " + adminToken) + .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated())