diff --git a/backend/src/main/java/de/effigenix/application/production/CancelBatch.java b/backend/src/main/java/de/effigenix/application/production/CancelBatch.java new file mode 100644 index 0000000..453b5ca --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/CancelBatch.java @@ -0,0 +1,57 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.CancelBatchCommand; +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 CancelBatch { + + private final BatchRepository batchRepository; + private final AuthorizationPort authorizationPort; + + public CancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + this.batchRepository = batchRepository; + this.authorizationPort = authorizationPort; + } + + public Result execute(CancelBatchCommand cmd, ActorId performedBy) { + if (!authorizationPort.can(performedBy, ProductionAction.BATCH_CANCEL)) { + return Result.failure(new BatchError.Unauthorized("Not authorized to cancel 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 CancelBatchDraft(cmd.reason()); + + switch (batch.cancel(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/CancelBatchCommand.java b/backend/src/main/java/de/effigenix/application/production/command/CancelBatchCommand.java new file mode 100644 index 0000000..102fdb5 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/production/command/CancelBatchCommand.java @@ -0,0 +1,3 @@ +package de.effigenix.application.production.command; + +public record CancelBatchCommand(String batchId, String reason) {} 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 efd7ec0..6b5cb1e 100644 --- a/backend/src/main/java/de/effigenix/domain/production/Batch.java +++ b/backend/src/main/java/de/effigenix/domain/production/Batch.java @@ -29,6 +29,8 @@ import java.util.List; * 11. At least one Consumption required before completion * 12. ActualQuantity must be positive * 13. Waste must be zero or positive + * 14. cancel() only allowed from PLANNED or IN_PRODUCTION status + * 15. CancellationReason must not be null or blank (max 500 chars) */ public class Batch { @@ -45,6 +47,8 @@ public class Batch { private final OffsetDateTime createdAt; private OffsetDateTime updatedAt; private OffsetDateTime completedAt; + private String cancellationReason; + private OffsetDateTime cancelledAt; private final long version; private final List consumptions; @@ -62,6 +66,8 @@ public class Batch { OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime completedAt, + String cancellationReason, + OffsetDateTime cancelledAt, long version, List consumptions ) { @@ -78,6 +84,8 @@ public class Batch { this.createdAt = createdAt; this.updatedAt = updatedAt; this.completedAt = completedAt; + this.cancellationReason = cancellationReason; + this.cancelledAt = cancelledAt; this.version = version; this.consumptions = consumptions; } @@ -133,6 +141,8 @@ public class Batch { now, now, null, + null, + null, 0L, new ArrayList<>() )); @@ -215,6 +225,29 @@ public class Batch { } } + static final int MAX_CANCELLATION_REASON_LENGTH = 500; + + public Result cancel(CancelBatchDraft draft) { + if (status == BatchStatus.COMPLETED || status == BatchStatus.CANCELLED) { + return Result.failure(new BatchError.InvalidStatusTransition(status, BatchStatus.CANCELLED)); + } + + if (draft.reason() == null || draft.reason().isBlank()) { + return Result.failure(new BatchError.CancellationReasonRequired()); + } + + if (draft.reason().length() > MAX_CANCELLATION_REASON_LENGTH) { + return Result.failure(new BatchError.CancellationReasonTooLong(MAX_CANCELLATION_REASON_LENGTH)); + } + + this.cancellationReason = draft.reason().strip(); + this.status = BatchStatus.CANCELLED; + var now = OffsetDateTime.now(ZoneOffset.UTC); + this.cancelledAt = now; + this.updatedAt = now; + return Result.success(null); + } + public Result recordConsumption(ConsumptionDraft draft) { if (status != BatchStatus.IN_PRODUCTION) { return Result.failure(new BatchError.NotInProduction(id)); @@ -237,6 +270,7 @@ public class Batch { return Result.success(consumption); } + // TODO: Replace 17-parameter signature with BatchSnapshot record (#72) public static Batch reconstitute( BatchId id, BatchNumber batchNumber, @@ -251,12 +285,14 @@ public class Batch { OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime completedAt, + String cancellationReason, + OffsetDateTime cancelledAt, long version, List consumptions ) { return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity, waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt, - completedAt, version, new ArrayList<>(consumptions)); + completedAt, cancellationReason, cancelledAt, version, new ArrayList<>(consumptions)); } public BatchId id() { return id; } @@ -272,6 +308,8 @@ public class Batch { public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime updatedAt() { return updatedAt; } public OffsetDateTime completedAt() { return completedAt; } + public String cancellationReason() { return cancellationReason; } + public OffsetDateTime cancelledAt() { return cancelledAt; } 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 060ef84..daf27b0 100644 --- a/backend/src/main/java/de/effigenix/domain/production/BatchError.java +++ b/backend/src/main/java/de/effigenix/domain/production/BatchError.java @@ -73,6 +73,16 @@ public sealed interface BatchError { @Override public String code() { return "UNAUTHORIZED"; } } + record CancellationReasonRequired() implements BatchError { + @Override public String code() { return "BATCH_CANCELLATION_REASON_REQUIRED"; } + @Override public String message() { return "A cancellation reason is required"; } + } + + record CancellationReasonTooLong(int maxLength) implements BatchError { + @Override public String code() { return "BATCH_CANCELLATION_REASON_TOO_LONG"; } + @Override public String message() { return "Cancellation reason must not exceed " + maxLength + " characters"; } + } + record RepositoryFailure(String message) implements BatchError { @Override public String code() { return "REPOSITORY_ERROR"; } } diff --git a/backend/src/main/java/de/effigenix/domain/production/CancelBatchDraft.java b/backend/src/main/java/de/effigenix/domain/production/CancelBatchDraft.java new file mode 100644 index 0000000..2db3f91 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/CancelBatchDraft.java @@ -0,0 +1,3 @@ +package de.effigenix.domain.production; + +public record CancelBatchDraft(String reason) {} diff --git a/backend/src/main/java/de/effigenix/domain/production/ProductionAction.java b/backend/src/main/java/de/effigenix/domain/production/ProductionAction.java index bca142a..e7ee5d0 100644 --- a/backend/src/main/java/de/effigenix/domain/production/ProductionAction.java +++ b/backend/src/main/java/de/effigenix/domain/production/ProductionAction.java @@ -21,6 +21,7 @@ public enum ProductionAction implements Action { BATCH_READ, BATCH_WRITE, BATCH_COMPLETE, + BATCH_CANCEL, BATCH_DELETE, // Production Orders diff --git a/backend/src/main/java/de/effigenix/domain/production/event/BatchCancelled.java b/backend/src/main/java/de/effigenix/domain/production/event/BatchCancelled.java new file mode 100644 index 0000000..c1e5361 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/production/event/BatchCancelled.java @@ -0,0 +1,15 @@ +package de.effigenix.domain.production.event; + +import de.effigenix.domain.production.BatchId; + +import java.time.OffsetDateTime; + +/** + * Stub – wird derzeit nicht publiziert. + * Vorgesehen für spätere Event-Infrastruktur (Audit, Tracing, Inventory reversal). + */ +public record BatchCancelled( + BatchId batchId, + String reason, + OffsetDateTime cancelledAt +) {} diff --git a/backend/src/main/java/de/effigenix/domain/usermanagement/Permission.java b/backend/src/main/java/de/effigenix/domain/usermanagement/Permission.java index 55e47fd..764e786 100644 --- a/backend/src/main/java/de/effigenix/domain/usermanagement/Permission.java +++ b/backend/src/main/java/de/effigenix/domain/usermanagement/Permission.java @@ -20,6 +20,7 @@ public enum Permission { BATCH_READ, BATCH_WRITE, BATCH_COMPLETE, + BATCH_CANCEL, BATCH_DELETE, PRODUCTION_ORDER_READ, 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 b8a4edf..3903fd6 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.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.FindBatchByNumber; @@ -114,4 +115,9 @@ public class ProductionUseCaseConfiguration { public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { return new CompleteBatch(batchRepository, authorizationPort); } + + @Bean + public CancelBatch cancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) { + return new CancelBatch(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 9ed1099..f12139b 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 @@ -65,6 +65,12 @@ public class BatchEntity { @Column(name = "completed_at") private OffsetDateTime completedAt; + @Column(name = "cancellation_reason", length = 500) + private String cancellationReason; + + @Column(name = "cancelled_at") + private OffsetDateTime cancelledAt; + @OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List consumptions = new ArrayList<>(); @@ -113,6 +119,8 @@ public class BatchEntity { public String getWasteUnit() { return wasteUnit; } public String getRemarks() { return remarks; } public OffsetDateTime getCompletedAt() { return completedAt; } + public String getCancellationReason() { return cancellationReason; } + public OffsetDateTime getCancelledAt() { return cancelledAt; } public void setStatus(String status) { this.status = status; } public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } @@ -122,4 +130,6 @@ public class BatchEntity { public void setWasteUnit(String wasteUnit) { this.wasteUnit = wasteUnit; } public void setRemarks(String remarks) { this.remarks = remarks; } public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; } + public void setCancellationReason(String cancellationReason) { this.cancellationReason = cancellationReason; } + public void setCancelledAt(OffsetDateTime cancelledAt) { this.cancelledAt = cancelledAt; } } 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 37c8a65..ec161a6 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 @@ -39,6 +39,8 @@ public class BatchMapper { } entity.setRemarks(batch.remarks()); entity.setCompletedAt(batch.completedAt()); + entity.setCancellationReason(batch.cancellationReason()); + entity.setCancelledAt(batch.cancelledAt()); for (Consumption c : batch.consumptions()) { entity.getConsumptions().add(toConsumptionEntity(c, entity)); @@ -67,6 +69,8 @@ public class BatchMapper { } entity.setRemarks(batch.remarks()); entity.setCompletedAt(batch.completedAt()); + entity.setCancellationReason(batch.cancellationReason()); + entity.setCancelledAt(batch.cancelledAt()); Set existingIds = entity.getConsumptions().stream() .map(ConsumptionEntity::getId) @@ -117,6 +121,8 @@ public class BatchMapper { entity.getCreatedAt(), entity.getUpdatedAt(), entity.getCompletedAt(), + entity.getCancellationReason(), + entity.getCancelledAt(), 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 d5addb5..e3f366d 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,5 +1,6 @@ package de.effigenix.infrastructure.production.web.controller; +import de.effigenix.application.production.CancelBatch; import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.GetBatch; @@ -7,6 +8,7 @@ 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.CancelBatchCommand; import de.effigenix.application.production.command.CompleteBatchCommand; import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.application.production.command.RecordConsumptionCommand; @@ -18,6 +20,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.CancelBatchRequest; import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest; import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest; @@ -52,10 +55,12 @@ public class BatchController { private final StartBatch startBatch; private final RecordConsumption recordConsumption; private final CompleteBatch completeBatch; + private final CancelBatch cancelBatch; public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, FindBatchByNumber findBatchByNumber, StartBatch startBatch, - RecordConsumption recordConsumption, CompleteBatch completeBatch) { + RecordConsumption recordConsumption, CompleteBatch completeBatch, + CancelBatch cancelBatch) { this.planBatch = planBatch; this.getBatch = getBatch; this.listBatches = listBatches; @@ -63,6 +68,7 @@ public class BatchController { this.startBatch = startBatch; this.recordConsumption = recordConsumption; this.completeBatch = completeBatch; + this.cancelBatch = cancelBatch; } @GetMapping("/{id}") @@ -238,6 +244,25 @@ public class BatchController { return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/cancel") + @PreAuthorize("hasAuthority('BATCH_CANCEL')") + public ResponseEntity cancelBatch( + @PathVariable("id") String id, + @Valid @RequestBody CancelBatchRequest request, + Authentication authentication + ) { + logger.info("Cancelling batch: {} by actor: {}", id, authentication.getName()); + + var cmd = new CancelBatchCommand(id, request.reason()); + var result = cancelBatch.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 cf1c7d2..9b73961 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 @@ -23,7 +23,9 @@ public record BatchResponse( List consumptions, OffsetDateTime createdAt, OffsetDateTime updatedAt, - OffsetDateTime completedAt + OffsetDateTime completedAt, + String cancellationReason, + OffsetDateTime cancelledAt ) { public static BatchResponse from(Batch batch) { var consumptions = batch.consumptions().stream() @@ -47,7 +49,9 @@ public record BatchResponse( consumptions, batch.createdAt(), batch.updatedAt(), - batch.completedAt() + batch.completedAt(), + batch.cancellationReason(), + batch.cancelledAt() ); } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CancelBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CancelBatchRequest.java new file mode 100644 index 0000000..1f27fd8 --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/production/web/dto/CancelBatchRequest.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.production.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CancelBatchRequest( + @NotBlank(message = "reason is required") + String reason +) {} 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 e5d7ad7..527448d 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 @@ -40,6 +40,8 @@ public final class ProductionErrorHttpStatusMapper { case BatchError.MissingConsumptions e -> 409; case BatchError.InvalidActualQuantity e -> 400; case BatchError.InvalidWaste e -> 400; + case BatchError.CancellationReasonRequired e -> 400; + case BatchError.CancellationReasonTooLong e -> 400; case BatchError.ValidationFailure e -> 400; case BatchError.Unauthorized e -> 403; case BatchError.RepositoryFailure e -> 500; diff --git a/backend/src/main/java/de/effigenix/infrastructure/security/ActionToPermissionMapper.java b/backend/src/main/java/de/effigenix/infrastructure/security/ActionToPermissionMapper.java index 97e419c..bc9c152 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/security/ActionToPermissionMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/security/ActionToPermissionMapper.java @@ -87,6 +87,7 @@ public class ActionToPermissionMapper { case BATCH_READ -> Permission.BATCH_READ; case BATCH_WRITE -> Permission.BATCH_WRITE; case BATCH_COMPLETE -> Permission.BATCH_COMPLETE; + case BATCH_CANCEL -> Permission.BATCH_CANCEL; case BATCH_DELETE -> Permission.BATCH_DELETE; case PRODUCTION_ORDER_READ -> Permission.PRODUCTION_ORDER_READ; case PRODUCTION_ORDER_WRITE -> Permission.PRODUCTION_ORDER_WRITE; diff --git a/backend/src/main/resources/db/changelog/changes/022-add-cancellation-fields-to-batches.xml b/backend/src/main/resources/db/changelog/changes/022-add-cancellation-fields-to-batches.xml new file mode 100644 index 0000000..d7e6442 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/022-add-cancellation-fields-to-batches.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/changes/023-add-batch-cancel-permission.xml b/backend/src/main/resources/db/changelog/changes/023-add-batch-cancel-permission.xml new file mode 100644 index 0000000..0d5cfde --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/023-add-batch-cancel-permission.xml @@ -0,0 +1,24 @@ + + + + + Add BATCH_CANCEL permission for ADMIN and PRODUCTION_MANAGER roles + + + + + + + + + + + + + + + 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 56e5e4d..339254d 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -26,5 +26,7 @@ + + diff --git a/backend/src/test/java/de/effigenix/application/production/CancelBatchTest.java b/backend/src/test/java/de/effigenix/application/production/CancelBatchTest.java new file mode 100644 index 0000000..15b442a --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/production/CancelBatchTest.java @@ -0,0 +1,213 @@ +package de.effigenix.application.production; + +import de.effigenix.application.production.command.CancelBatchCommand; +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("CancelBatch Use Case") +class CancelBatchTest { + + @Mock private BatchRepository batchRepository; + @Mock private AuthorizationPort authPort; + + private CancelBatch cancelBatch; + private ActorId performedBy; + + @BeforeEach + void setUp() { + cancelBatch = new CancelBatch(batchRepository, authPort); + performedBy = ActorId.of("admin-user"); + } + + private Batch plannedBatch(String id) { + return Batch.reconstitute( + BatchId.of(id), + 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, null, null, + 0L, List.of() + ); + } + + private Batch inProductionBatch(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, null, null, + 0L, List.of() + ); + } + + private CancelBatchCommand validCommand(String batchId) { + return new CancelBatchCommand(batchId, "Qualitätsproblem erkannt"); + } + + @Test + @DisplayName("should cancel PLANNED batch") + void should_CancelBatch_When_Planned() { + var batchId = BatchId.of("batch-1"); + var batch = plannedBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + + var result = cancelBatch.execute(validCommand("batch-1"), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.CANCELLED); + assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Qualitätsproblem erkannt"); + assertThat(result.unsafeGetValue().cancelledAt()).isNotNull(); + verify(batchRepository).save(batch); + } + + @Test + @DisplayName("should cancel IN_PRODUCTION batch") + void should_CancelBatch_When_InProduction() { + var batchId = BatchId.of("batch-1"); + var batch = inProductionBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + when(batchRepository.save(any())).thenReturn(Result.success(null)); + + var result = cancelBatch.execute(validCommand("batch-1"), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(BatchStatus.CANCELLED); + verify(batchRepository).save(batch); + } + + @Test + @DisplayName("should fail with Unauthorized when actor lacks permission") + void should_FailWithUnauthorized_When_ActorLacksPermission() { + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(false); + + var result = cancelBatch.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 when batch not found") + void should_Fail_When_BatchNotFound() { + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(any())).thenReturn(Result.success(Optional.empty())); + + var result = cancelBatch.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 batch is COMPLETED") + void should_Fail_When_BatchIsCompleted() { + var batchId = BatchId.of("batch-1"); + var batch = Batch.reconstitute( + batchId, + BatchNumber.generate(LocalDate.of(2026, 3, 1), 1), + RecipeId.of("recipe-1"), + BatchStatus.COMPLETED, + 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, null, null, + 0L, List.of() + ); + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + + var result = cancelBatch.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 reason is blank") + void should_Fail_When_ReasonBlank() { + var batchId = BatchId.of("batch-1"); + var batch = plannedBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(batchId)).thenReturn(Result.success(Optional.of(batch))); + + var cmd = new CancelBatchCommand("batch-1", " "); + var result = cancelBatch.execute(cmd, performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class); + verify(batchRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure on repository error during find") + void should_FailWithRepositoryFailure_When_FindFails() { + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).thenReturn(true); + when(batchRepository.findById(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = cancelBatch.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 = plannedBatch("batch-1"); + when(authPort.can(performedBy, ProductionAction.BATCH_CANCEL)).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 = cancelBatch.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/CompleteBatchTest.java b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java index 64334e3..e60f7f1 100644 --- a/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/CompleteBatchTest.java @@ -54,6 +54,7 @@ class CompleteBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, null, 0L, List.of() ); batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM")); @@ -73,6 +74,7 @@ class CompleteBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, null, 0L, List.of() ); } @@ -160,6 +162,7 @@ class CompleteBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, null, 0L, List.of() ); when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true); 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 5e5484c..c2d1b95 100644 --- a/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java +++ b/backend/src/test/java/de/effigenix/application/production/FindBatchByNumberTest.java @@ -55,6 +55,7 @@ class FindBatchByNumberTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, 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 e15f5e8..92ae75c 100644 --- a/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/GetBatchTest.java @@ -53,6 +53,7 @@ class GetBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, 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 94bccde..4de2203 100644 --- a/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java +++ b/backend/src/test/java/de/effigenix/application/production/ListBatchesTest.java @@ -57,6 +57,7 @@ class ListBatchesTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, 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 da830fa..9be4d15 100644 --- a/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java +++ b/backend/src/test/java/de/effigenix/application/production/RecordConsumptionTest.java @@ -54,6 +54,7 @@ class RecordConsumptionTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, null, 0L, List.of() ); } @@ -71,6 +72,7 @@ class RecordConsumptionTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, 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 c2f343d..d7dbb67 100644 --- a/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java +++ b/backend/src/test/java/de/effigenix/application/production/StartBatchTest.java @@ -54,6 +54,7 @@ class StartBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, null, 0L, List.of() ); } @@ -71,6 +72,7 @@ class StartBatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, 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 753569d..538b1e4 100644 --- a/backend/src/test/java/de/effigenix/domain/production/BatchTest.java +++ b/backend/src/test/java/de/effigenix/domain/production/BatchTest.java @@ -212,6 +212,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); @@ -235,6 +237,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); @@ -255,6 +259,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); @@ -321,6 +327,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"); @@ -447,6 +455,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null); @@ -517,6 +527,8 @@ class BatchTest { PRODUCTION_DATE, BEST_BEFORE_DATE, OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + null, 0L, List.of() ); var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null); @@ -621,6 +633,167 @@ class BatchTest { } } + @Nested + @DisplayName("cancel()") + class Cancel { + + @Test + @DisplayName("should cancel PLANNED batch") + void should_Cancel_When_Planned() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + var draft = new CancelBatchDraft("Fehlplanung"); + + var result = batch.cancel(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED); + assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung"); + assertThat(batch.cancelledAt()).isNotNull(); + } + + @Test + @DisplayName("should cancel IN_PRODUCTION batch") + void should_Cancel_When_InProduction() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + batch.startProduction(); + var draft = new CancelBatchDraft("Qualitätsproblem"); + + var result = batch.cancel(draft); + + assertThat(result.isSuccess()).isTrue(); + assertThat(batch.status()).isEqualTo(BatchStatus.CANCELLED); + assertThat(batch.cancellationReason()).isEqualTo("Qualitätsproblem"); + } + + @Test + @DisplayName("should fail when COMPLETED") + void should_Fail_When_Completed() { + 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, null, null, + 0L, List.of() + ); + + var result = batch.cancel(new CancelBatchDraft("Grund")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class); + var err = (BatchError.InvalidStatusTransition) result.unsafeGetError(); + assertThat(err.current()).isEqualTo(BatchStatus.COMPLETED); + assertThat(err.target()).isEqualTo(BatchStatus.CANCELLED); + } + + @Test + @DisplayName("should fail when already CANCELLED") + void should_Fail_When_AlreadyCancelled() { + 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, null, null, + 0L, List.of() + ); + + var result = batch.cancel(new CancelBatchDraft("Grund")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when reason is null") + void should_Fail_When_ReasonNull() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + + var result = batch.cancel(new CancelBatchDraft(null)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class); + } + + @Test + @DisplayName("should fail when reason is blank") + void should_Fail_When_ReasonBlank() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + + var result = batch.cancel(new CancelBatchDraft(" ")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonRequired.class); + } + + @Test + @DisplayName("should fail when reason exceeds 500 characters") + void should_Fail_When_ReasonTooLong() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + var longReason = "A".repeat(501); + + var result = batch.cancel(new CancelBatchDraft(longReason)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(BatchError.CancellationReasonTooLong.class); + assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED); + } + + @Test + @DisplayName("should accept reason with exactly 500 characters") + void should_Accept_When_ReasonExactly500() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + var reason = "A".repeat(500); + + var result = batch.cancel(new CancelBatchDraft(reason)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(batch.cancellationReason()).isEqualTo(reason); + } + + @Test + @DisplayName("should strip whitespace from reason") + void should_StripWhitespace_When_Cancelling() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + + var result = batch.cancel(new CancelBatchDraft(" Fehlplanung ")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(batch.cancellationReason()).isEqualTo("Fehlplanung"); + } + + @Test + @DisplayName("should set cancelledAt and updatedAt timestamps") + void should_SetTimestamps_When_Cancelling() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + var beforeCancel = batch.updatedAt(); + + batch.cancel(new CancelBatchDraft("Grund")); + + assertThat(batch.updatedAt()).isAfterOrEqualTo(beforeCancel); + assertThat(batch.cancelledAt()).isNotNull(); + assertThat(batch.cancelledAt()).isEqualTo(batch.updatedAt()); + } + + @Test + @DisplayName("should not mutate state on validation failure") + void should_NotMutateState_When_ValidationFails() { + var batch = Batch.plan(validDraft(), BATCH_NUMBER).unsafeGetValue(); + var beforeUpdate = batch.updatedAt(); + + batch.cancel(new CancelBatchDraft(null)); + + assertThat(batch.status()).isEqualTo(BatchStatus.PLANNED); + assertThat(batch.cancellationReason()).isNull(); + assertThat(batch.cancelledAt()).isNull(); + assertThat(batch.updatedAt()).isEqualTo(beforeUpdate); + } + } + @Nested @DisplayName("reconstitute()") class Reconstitute { @@ -640,6 +813,8 @@ class BatchTest { OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), null, + null, + 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 34e900f..80768a1 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 @@ -520,6 +520,156 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest { } } + @Nested + @DisplayName("POST /api/production/batches/{id}/cancel – Charge stornieren") + class CancelBatchEndpoint { + + private String cancelToken; + + @BeforeEach + void setUpCancelToken() { + cancelToken = generateToken(UUID.randomUUID().toString(), "cancel.admin", + "BATCH_WRITE,BATCH_READ,BATCH_CANCEL,RECIPE_WRITE,RECIPE_READ"); + } + + private String createPlannedBatch() throws Exception { + String recipeId = createActiveRecipeWith(cancelToken); + var planRequest = new PlanBatchRequest( + recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE); + var planResult = mockMvc.perform(post("/api/production/batches") + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(planRequest))) + .andExpect(status().isCreated()) + .andReturn(); + return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText(); + } + + private String createInProductionBatch() throws Exception { + String batchId = createPlannedBatch(); + mockMvc.perform(post("/api/production/batches/{id}/start", batchId) + .header("Authorization", "Bearer " + cancelToken)) + .andExpect(status().isOk()); + return batchId; + } + + @Test + @DisplayName("PLANNED Charge stornieren → 200, Status CANCELLED") + void cancelBatch_planned_returns200() throws Exception { + String batchId = createPlannedBatch(); + + String cancelJson = """ + {"reason": "Fehlplanung"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CANCELLED")) + .andExpect(jsonPath("$.cancellationReason").value("Fehlplanung")) + .andExpect(jsonPath("$.cancelledAt").isNotEmpty()); + } + + @Test + @DisplayName("IN_PRODUCTION Charge stornieren → 200, Status CANCELLED") + void cancelBatch_inProduction_returns200() throws Exception { + String batchId = createInProductionBatch(); + + String cancelJson = """ + {"reason": "Qualitätsproblem"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CANCELLED")) + .andExpect(jsonPath("$.cancellationReason").value("Qualitätsproblem")); + } + + @Test + @DisplayName("Bereits stornierte Charge erneut stornieren → 409") + void cancelBatch_alreadyCancelled_returns409() throws Exception { + String batchId = createPlannedBatch(); + + String cancelJson = """ + {"reason": "Erster Grund"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/production/batches/{id}/cancel", batchId) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Reason leer → 400 (Bean Validation)") + void cancelBatch_blankReason_returns400() throws Exception { + String cancelJson = """ + {"reason": ""} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Batch nicht gefunden → 404") + void cancelBatch_notFound_returns404() throws Exception { + String cancelJson = """ + {"reason": "Grund"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + cancelToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND")); + } + + @Test + @DisplayName("Ohne Token → 401") + void cancelBatch_withoutToken_returns401() throws Exception { + String cancelJson = """ + {"reason": "Grund"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Ohne BATCH_CANCEL → 403") + void cancelBatch_withoutPermission_returns403() throws Exception { + String cancelJson = """ + {"reason": "Grund"} + """; + + mockMvc.perform(post("/api/production/batches/{id}/cancel", UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelJson)) + .andExpect(status().isForbidden()); + } + } + // ==================== Hilfsmethoden ==================== private String createActiveRecipe() throws Exception {