mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 12:19: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:
parent
a08e4194ab
commit
3c660650e5
28 changed files with 783 additions and 4 deletions
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package de.effigenix.application.production.command;
|
||||||
|
|
||||||
|
public record CancelBatchCommand(String batchId, String reason) {}
|
||||||
|
|
@ -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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package de.effigenix.domain.production;
|
||||||
|
|
||||||
|
public record CancelBatchDraft(String reason) {}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue