1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:39:35 +01:00

feat(production): Charge stornieren (CancelBatch)

PLANNED und IN_PRODUCTION Chargen können mit Angabe eines
Stornierungsgrundes storniert werden. COMPLETED und bereits
CANCELLED Chargen werden abgelehnt.
This commit is contained in:
Sebastian Frick 2026-02-23 14:07:02 +01:00
parent a08e4194ab
commit 3c660650e5
28 changed files with 783 additions and 4 deletions

View file

@ -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<BatchError, Batch> 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);
}
}

View file

@ -0,0 +1,3 @@
package de.effigenix.application.production.command;
public record CancelBatchCommand(String batchId, String reason) {}

View file

@ -29,6 +29,8 @@ import java.util.List;
* 11. At least one Consumption required before completion * 11. At least one Consumption required before completion
* 12. ActualQuantity must be positive * 12. ActualQuantity must be positive
* 13. Waste must be zero or 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 { public class Batch {
@ -45,6 +47,8 @@ public class Batch {
private final OffsetDateTime createdAt; private final OffsetDateTime createdAt;
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
private OffsetDateTime completedAt; private OffsetDateTime completedAt;
private String cancellationReason;
private OffsetDateTime cancelledAt;
private final long version; private final long version;
private final List<Consumption> consumptions; private final List<Consumption> consumptions;
@ -62,6 +66,8 @@ public class Batch {
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
OffsetDateTime completedAt, OffsetDateTime completedAt,
String cancellationReason,
OffsetDateTime cancelledAt,
long version, long version,
List<Consumption> consumptions List<Consumption> consumptions
) { ) {
@ -78,6 +84,8 @@ public class Batch {
this.createdAt = createdAt; this.createdAt = createdAt;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
this.completedAt = completedAt; this.completedAt = completedAt;
this.cancellationReason = cancellationReason;
this.cancelledAt = cancelledAt;
this.version = version; this.version = version;
this.consumptions = consumptions; this.consumptions = consumptions;
} }
@ -133,6 +141,8 @@ public class Batch {
now, now,
now, now,
null, null,
null,
null,
0L, 0L,
new ArrayList<>() new ArrayList<>()
)); ));
@ -215,6 +225,29 @@ public class Batch {
} }
} }
static final int MAX_CANCELLATION_REASON_LENGTH = 500;
public Result<BatchError, Void> 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<BatchError, Consumption> recordConsumption(ConsumptionDraft draft) { public Result<BatchError, Consumption> recordConsumption(ConsumptionDraft draft) {
if (status != BatchStatus.IN_PRODUCTION) { if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.NotInProduction(id)); return Result.failure(new BatchError.NotInProduction(id));
@ -237,6 +270,7 @@ public class Batch {
return Result.success(consumption); return Result.success(consumption);
} }
// TODO: Replace 17-parameter signature with BatchSnapshot record (#72)
public static Batch reconstitute( public static Batch reconstitute(
BatchId id, BatchId id,
BatchNumber batchNumber, BatchNumber batchNumber,
@ -251,12 +285,14 @@ public class Batch {
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
OffsetDateTime completedAt, OffsetDateTime completedAt,
String cancellationReason,
OffsetDateTime cancelledAt,
long version, long version,
List<Consumption> consumptions List<Consumption> consumptions
) { ) {
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity, return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity,
waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt, waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt,
completedAt, version, new ArrayList<>(consumptions)); completedAt, cancellationReason, cancelledAt, version, new ArrayList<>(consumptions));
} }
public BatchId id() { return id; } public BatchId id() { return id; }
@ -272,6 +308,8 @@ public class Batch {
public OffsetDateTime createdAt() { return createdAt; } public OffsetDateTime createdAt() { return createdAt; }
public OffsetDateTime updatedAt() { return updatedAt; } public OffsetDateTime updatedAt() { return updatedAt; }
public OffsetDateTime completedAt() { return completedAt; } public OffsetDateTime completedAt() { return completedAt; }
public String cancellationReason() { return cancellationReason; }
public OffsetDateTime cancelledAt() { return cancelledAt; }
public long version() { return version; } public long version() { return version; }
public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); } public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); }
} }

View file

@ -73,6 +73,16 @@ public sealed interface BatchError {
@Override public String code() { return "UNAUTHORIZED"; } @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 { record RepositoryFailure(String message) implements BatchError {
@Override public String code() { return "REPOSITORY_ERROR"; } @Override public String code() { return "REPOSITORY_ERROR"; }
} }

View file

@ -0,0 +1,3 @@
package de.effigenix.domain.production;
public record CancelBatchDraft(String reason) {}

View file

@ -21,6 +21,7 @@ public enum ProductionAction implements Action {
BATCH_READ, BATCH_READ,
BATCH_WRITE, BATCH_WRITE,
BATCH_COMPLETE, BATCH_COMPLETE,
BATCH_CANCEL,
BATCH_DELETE, BATCH_DELETE,
// Production Orders // Production Orders

View file

@ -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
) {}

View file

@ -20,6 +20,7 @@ public enum Permission {
BATCH_READ, BATCH_READ,
BATCH_WRITE, BATCH_WRITE,
BATCH_COMPLETE, BATCH_COMPLETE,
BATCH_CANCEL,
BATCH_DELETE, BATCH_DELETE,
PRODUCTION_ORDER_READ, PRODUCTION_ORDER_READ,

View file

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

View file

@ -65,6 +65,12 @@ public class BatchEntity {
@Column(name = "completed_at") @Column(name = "completed_at")
private OffsetDateTime completedAt; 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) @OneToMany(mappedBy = "batch", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ConsumptionEntity> consumptions = new ArrayList<>(); private List<ConsumptionEntity> consumptions = new ArrayList<>();
@ -113,6 +119,8 @@ public class BatchEntity {
public String getWasteUnit() { return wasteUnit; } public String getWasteUnit() { return wasteUnit; }
public String getRemarks() { return remarks; } public String getRemarks() { return remarks; }
public OffsetDateTime getCompletedAt() { return completedAt; } 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 setStatus(String status) { this.status = status; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } 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 setWasteUnit(String wasteUnit) { this.wasteUnit = wasteUnit; }
public void setRemarks(String remarks) { this.remarks = remarks; } public void setRemarks(String remarks) { this.remarks = remarks; }
public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; } public void setCompletedAt(OffsetDateTime completedAt) { this.completedAt = completedAt; }
public void setCancellationReason(String cancellationReason) { this.cancellationReason = cancellationReason; }
public void setCancelledAt(OffsetDateTime cancelledAt) { this.cancelledAt = cancelledAt; }
} }

View file

@ -39,6 +39,8 @@ public class BatchMapper {
} }
entity.setRemarks(batch.remarks()); entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt()); entity.setCompletedAt(batch.completedAt());
entity.setCancellationReason(batch.cancellationReason());
entity.setCancelledAt(batch.cancelledAt());
for (Consumption c : batch.consumptions()) { for (Consumption c : batch.consumptions()) {
entity.getConsumptions().add(toConsumptionEntity(c, entity)); entity.getConsumptions().add(toConsumptionEntity(c, entity));
@ -67,6 +69,8 @@ public class BatchMapper {
} }
entity.setRemarks(batch.remarks()); entity.setRemarks(batch.remarks());
entity.setCompletedAt(batch.completedAt()); entity.setCompletedAt(batch.completedAt());
entity.setCancellationReason(batch.cancellationReason());
entity.setCancelledAt(batch.cancelledAt());
Set<String> existingIds = entity.getConsumptions().stream() Set<String> existingIds = entity.getConsumptions().stream()
.map(ConsumptionEntity::getId) .map(ConsumptionEntity::getId)
@ -117,6 +121,8 @@ public class BatchMapper {
entity.getCreatedAt(), entity.getCreatedAt(),
entity.getUpdatedAt(), entity.getUpdatedAt(),
entity.getCompletedAt(), entity.getCompletedAt(),
entity.getCancellationReason(),
entity.getCancelledAt(),
entity.getVersion(), entity.getVersion(),
consumptions consumptions
); );

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.production.web.controller; package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.CancelBatch;
import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch; import de.effigenix.application.production.GetBatch;
@ -7,6 +8,7 @@ import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecordConsumption; import de.effigenix.application.production.RecordConsumption;
import de.effigenix.application.production.StartBatch; import de.effigenix.application.production.StartBatch;
import de.effigenix.application.production.command.CancelBatchCommand;
import de.effigenix.application.production.command.CompleteBatchCommand; import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.application.production.command.RecordConsumptionCommand;
@ -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.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse; import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse;
import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse; import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse;
import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest; import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest; import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest; import de.effigenix.infrastructure.production.web.dto.RecordConsumptionRequest;
@ -52,10 +55,12 @@ public class BatchController {
private final StartBatch startBatch; private final StartBatch startBatch;
private final RecordConsumption recordConsumption; private final RecordConsumption recordConsumption;
private final CompleteBatch completeBatch; private final CompleteBatch completeBatch;
private final CancelBatch cancelBatch;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch, FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption, CompleteBatch completeBatch) { RecordConsumption recordConsumption, CompleteBatch completeBatch,
CancelBatch cancelBatch) {
this.planBatch = planBatch; this.planBatch = planBatch;
this.getBatch = getBatch; this.getBatch = getBatch;
this.listBatches = listBatches; this.listBatches = listBatches;
@ -63,6 +68,7 @@ public class BatchController {
this.startBatch = startBatch; this.startBatch = startBatch;
this.recordConsumption = recordConsumption; this.recordConsumption = recordConsumption;
this.completeBatch = completeBatch; this.completeBatch = completeBatch;
this.cancelBatch = cancelBatch;
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@ -238,6 +244,25 @@ public class BatchController {
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue())); return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
} }
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('BATCH_CANCEL')")
public ResponseEntity<BatchResponse> 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) { private static String filterType(String status, LocalDate productionDate, String articleId) {
int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0); int count = (status != null ? 1 : 0) + (productionDate != null ? 1 : 0) + (articleId != null ? 1 : 0);
if (count > 1) return "ambiguous"; if (count > 1) return "ambiguous";

View file

@ -23,7 +23,9 @@ public record BatchResponse(
List<ConsumptionResponse> consumptions, List<ConsumptionResponse> consumptions,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
OffsetDateTime completedAt OffsetDateTime completedAt,
String cancellationReason,
OffsetDateTime cancelledAt
) { ) {
public static BatchResponse from(Batch batch) { public static BatchResponse from(Batch batch) {
var consumptions = batch.consumptions().stream() var consumptions = batch.consumptions().stream()
@ -47,7 +49,9 @@ public record BatchResponse(
consumptions, consumptions,
batch.createdAt(), batch.createdAt(),
batch.updatedAt(), batch.updatedAt(),
batch.completedAt() batch.completedAt(),
batch.cancellationReason(),
batch.cancelledAt()
); );
} }
} }

View file

@ -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
) {}

View file

@ -40,6 +40,8 @@ public final class ProductionErrorHttpStatusMapper {
case BatchError.MissingConsumptions e -> 409; case BatchError.MissingConsumptions e -> 409;
case BatchError.InvalidActualQuantity e -> 400; case BatchError.InvalidActualQuantity e -> 400;
case BatchError.InvalidWaste e -> 400; case BatchError.InvalidWaste e -> 400;
case BatchError.CancellationReasonRequired e -> 400;
case BatchError.CancellationReasonTooLong e -> 400;
case BatchError.ValidationFailure e -> 400; case BatchError.ValidationFailure e -> 400;
case BatchError.Unauthorized e -> 403; case BatchError.Unauthorized e -> 403;
case BatchError.RepositoryFailure e -> 500; case BatchError.RepositoryFailure e -> 500;

View file

@ -87,6 +87,7 @@ public class ActionToPermissionMapper {
case BATCH_READ -> Permission.BATCH_READ; case BATCH_READ -> Permission.BATCH_READ;
case BATCH_WRITE -> Permission.BATCH_WRITE; case BATCH_WRITE -> Permission.BATCH_WRITE;
case BATCH_COMPLETE -> Permission.BATCH_COMPLETE; case BATCH_COMPLETE -> Permission.BATCH_COMPLETE;
case BATCH_CANCEL -> Permission.BATCH_CANCEL;
case BATCH_DELETE -> Permission.BATCH_DELETE; case BATCH_DELETE -> Permission.BATCH_DELETE;
case PRODUCTION_ORDER_READ -> Permission.PRODUCTION_ORDER_READ; case PRODUCTION_ORDER_READ -> Permission.PRODUCTION_ORDER_READ;
case PRODUCTION_ORDER_WRITE -> Permission.PRODUCTION_ORDER_WRITE; case PRODUCTION_ORDER_WRITE -> Permission.PRODUCTION_ORDER_WRITE;

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="022-add-cancellation-fields-to-batches" author="effigenix">
<addColumn tableName="batches">
<column name="cancellation_reason" type="VARCHAR(500)"/>
<column name="cancelled_at" type="TIMESTAMPTZ"/>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="023-add-batch-cancel-permission" author="effigenix">
<comment>Add BATCH_CANCEL permission for ADMIN and PRODUCTION_MANAGER roles</comment>
<!-- ADMIN -->
<insert tableName="role_permissions">
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
<column name="permission" value="BATCH_CANCEL"/>
</insert>
<!-- PRODUCTION_MANAGER -->
<insert tableName="role_permissions">
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
<column name="permission" value="BATCH_CANCEL"/>
</insert>
</changeSet>
</databaseChangeLog>

View file

@ -26,5 +26,7 @@
<include file="db/changelog/changes/019-create-batch-consumptions-table.xml"/> <include file="db/changelog/changes/019-create-batch-consumptions-table.xml"/>
<include file="db/changelog/changes/020-add-version-to-batches.xml"/> <include file="db/changelog/changes/020-add-version-to-batches.xml"/>
<include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/> <include file="db/changelog/changes/021-add-completion-fields-to-batches.xml"/>
<include file="db/changelog/changes/022-add-cancellation-fields-to-batches.xml"/>
<include file="db/changelog/changes/023-add-batch-cancel-permission.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -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);
}
}

View file

@ -54,6 +54,7 @@ class CompleteBatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
batch.recordConsumption(new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM")); 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),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }
@ -160,6 +162,7 @@ class CompleteBatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true); when(authPort.can(performedBy, ProductionAction.BATCH_COMPLETE)).thenReturn(true);

View file

@ -55,6 +55,7 @@ class FindBatchByNumberTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -53,6 +53,7 @@ class GetBatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -57,6 +57,7 @@ class ListBatchesTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -54,6 +54,7 @@ class RecordConsumptionTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }
@ -71,6 +72,7 @@ class RecordConsumptionTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -54,6 +54,7 @@ class StartBatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }
@ -71,6 +72,7 @@ class StartBatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null, null,
0L, List.of() 0L, List.of()
); );
} }

View file

@ -212,6 +212,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
@ -235,6 +237,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
@ -255,6 +259,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
@ -321,6 +327,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM"); var draft = new ConsumptionDraft("input-1", "article-1", "5.0", "KILOGRAM");
@ -447,6 +455,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null); var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null);
@ -517,6 +527,8 @@ class BatchTest {
PRODUCTION_DATE, BEST_BEFORE_DATE, PRODUCTION_DATE, BEST_BEFORE_DATE,
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );
var draft = new CompleteBatchDraft("95", "KILOGRAM", "5", "KILOGRAM", null); 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 @Nested
@DisplayName("reconstitute()") @DisplayName("reconstitute()")
class Reconstitute { class Reconstitute {
@ -640,6 +813,8 @@ class BatchTest {
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC), OffsetDateTime.now(ZoneOffset.UTC),
null, null,
null,
null,
0L, List.of() 0L, List.of()
); );

View file

@ -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 ==================== // ==================== Hilfsmethoden ====================
private String createActiveRecipe() throws Exception { private String createActiveRecipe() throws Exception {