1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49: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
* 12. ActualQuantity must be positive
* 13. Waste must be zero or positive
* 14. cancel() only allowed from PLANNED or IN_PRODUCTION status
* 15. CancellationReason must not be null or blank (max 500 chars)
*/
public class Batch {
@ -45,6 +47,8 @@ public class Batch {
private final OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime completedAt;
private String cancellationReason;
private OffsetDateTime cancelledAt;
private final long version;
private final List<Consumption> consumptions;
@ -62,6 +66,8 @@ public class Batch {
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime completedAt,
String cancellationReason,
OffsetDateTime cancelledAt,
long version,
List<Consumption> consumptions
) {
@ -78,6 +84,8 @@ public class Batch {
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.completedAt = completedAt;
this.cancellationReason = cancellationReason;
this.cancelledAt = cancelledAt;
this.version = version;
this.consumptions = consumptions;
}
@ -133,6 +141,8 @@ public class Batch {
now,
now,
null,
null,
null,
0L,
new ArrayList<>()
));
@ -215,6 +225,29 @@ public class Batch {
}
}
static final int MAX_CANCELLATION_REASON_LENGTH = 500;
public Result<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) {
if (status != BatchStatus.IN_PRODUCTION) {
return Result.failure(new BatchError.NotInProduction(id));
@ -237,6 +270,7 @@ public class Batch {
return Result.success(consumption);
}
// TODO: Replace 17-parameter signature with BatchSnapshot record (#72)
public static Batch reconstitute(
BatchId id,
BatchNumber batchNumber,
@ -251,12 +285,14 @@ public class Batch {
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime completedAt,
String cancellationReason,
OffsetDateTime cancelledAt,
long version,
List<Consumption> consumptions
) {
return new Batch(id, batchNumber, recipeId, status, plannedQuantity, actualQuantity,
waste, remarks, productionDate, bestBeforeDate, createdAt, updatedAt,
completedAt, version, new ArrayList<>(consumptions));
completedAt, cancellationReason, cancelledAt, version, new ArrayList<>(consumptions));
}
public BatchId id() { return id; }
@ -272,6 +308,8 @@ public class Batch {
public OffsetDateTime createdAt() { return createdAt; }
public OffsetDateTime updatedAt() { return updatedAt; }
public OffsetDateTime completedAt() { return completedAt; }
public String cancellationReason() { return cancellationReason; }
public OffsetDateTime cancelledAt() { return cancelledAt; }
public long version() { return version; }
public List<Consumption> consumptions() { return Collections.unmodifiableList(consumptions); }
}

View file

@ -73,6 +73,16 @@ public sealed interface BatchError {
@Override public String code() { return "UNAUTHORIZED"; }
}
record CancellationReasonRequired() implements BatchError {
@Override public String code() { return "BATCH_CANCELLATION_REASON_REQUIRED"; }
@Override public String message() { return "A cancellation reason is required"; }
}
record CancellationReasonTooLong(int maxLength) implements BatchError {
@Override public String code() { return "BATCH_CANCELLATION_REASON_TOO_LONG"; }
@Override public String message() { return "Cancellation reason must not exceed " + maxLength + " characters"; }
}
record RepositoryFailure(String message) implements BatchError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}

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_WRITE,
BATCH_COMPLETE,
BATCH_CANCEL,
BATCH_DELETE,
// 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_WRITE,
BATCH_COMPLETE,
BATCH_CANCEL,
BATCH_DELETE,
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.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CancelBatch;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.FindBatchByNumber;
@ -114,4 +115,9 @@ public class ProductionUseCaseConfiguration {
public CompleteBatch completeBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new CompleteBatch(batchRepository, authorizationPort);
}
@Bean
public CancelBatch cancelBatch(BatchRepository batchRepository, AuthorizationPort authorizationPort) {
return new CancelBatch(batchRepository, authorizationPort);
}
}

View file

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

View file

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

View file

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

View file

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

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.InvalidActualQuantity e -> 400;
case BatchError.InvalidWaste e -> 400;
case BatchError.CancellationReasonRequired e -> 400;
case BatchError.CancellationReasonTooLong e -> 400;
case BatchError.ValidationFailure e -> 400;
case BatchError.Unauthorized e -> 403;
case BatchError.RepositoryFailure e -> 500;

View file

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

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/020-add-version-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>