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

feat(production): Produktionsauftrag abschließen und stornieren (US-P16)

Complete: IN_PROGRESS → COMPLETED (nur wenn Batch COMPLETED)
Cancel: PLANNED/RELEASED → CANCELLED (nicht aus IN_PROGRESS)

Domain-, UseCase-, Integration- und Lasttests ergänzt.
This commit is contained in:
Sebastian Frick 2026-02-25 21:41:45 +01:00
parent 14b59722f7
commit 72d59b4948
17 changed files with 1216 additions and 2 deletions

View file

@ -0,0 +1,60 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CancelProductionOrderCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
public class CancelProductionOrder {
private final ProductionOrderRepository productionOrderRepository;
private final AuthorizationPort authorizationPort;
private final UnitOfWork unitOfWork;
public CancelProductionOrder(
ProductionOrderRepository productionOrderRepository,
AuthorizationPort authorizationPort,
UnitOfWork unitOfWork
) {
this.productionOrderRepository = productionOrderRepository;
this.authorizationPort = authorizationPort;
this.unitOfWork = unitOfWork;
}
public Result<ProductionOrderError, ProductionOrder> execute(CancelProductionOrderCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) {
return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to cancel production orders"));
}
var orderId = ProductionOrderId.of(cmd.productionOrderId());
ProductionOrder order;
switch (productionOrderRepository.findById(orderId)) {
case Result.Failure(var err) -> {
return Result.failure(new ProductionOrderError.RepositoryFailure(err.message()));
}
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new ProductionOrderError.ProductionOrderNotFound(orderId));
}
order = opt.get();
}
}
switch (order.cancel()) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
return unitOfWork.executeAtomically(() -> {
switch (productionOrderRepository.save(order)) {
case Result.Failure(var err) -> {
return Result.failure(new ProductionOrderError.RepositoryFailure(err.message()));
}
case Result.Success(var ignored) -> { }
}
return Result.success(order);
});
}
}

View file

@ -0,0 +1,86 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.CompleteProductionOrderCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
public class CompleteProductionOrder {
private final ProductionOrderRepository productionOrderRepository;
private final BatchRepository batchRepository;
private final AuthorizationPort authorizationPort;
private final UnitOfWork unitOfWork;
public CompleteProductionOrder(
ProductionOrderRepository productionOrderRepository,
BatchRepository batchRepository,
AuthorizationPort authorizationPort,
UnitOfWork unitOfWork
) {
this.productionOrderRepository = productionOrderRepository;
this.batchRepository = batchRepository;
this.authorizationPort = authorizationPort;
this.unitOfWork = unitOfWork;
}
public Result<ProductionOrderError, ProductionOrder> execute(CompleteProductionOrderCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.PRODUCTION_ORDER_WRITE)) {
return Result.failure(new ProductionOrderError.Unauthorized("Not authorized to complete production orders"));
}
var orderId = ProductionOrderId.of(cmd.productionOrderId());
ProductionOrder order;
switch (productionOrderRepository.findById(orderId)) {
case Result.Failure(var err) -> {
return Result.failure(new ProductionOrderError.RepositoryFailure(err.message()));
}
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new ProductionOrderError.ProductionOrderNotFound(orderId));
}
order = opt.get();
}
}
// Batch must be COMPLETED (defense in depth domain checks status transition)
var batchId = order.batchId();
if (batchId == null) {
return Result.failure(new ProductionOrderError.ValidationFailure("Production order has no batch assigned"));
}
Batch batch;
switch (batchRepository.findById(batchId)) {
case Result.Failure(var err) -> {
return Result.failure(new ProductionOrderError.RepositoryFailure(err.message()));
}
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new ProductionOrderError.ValidationFailure("Batch '" + batchId.value() + "' not found"));
}
batch = opt.get();
}
}
if (batch.status() != BatchStatus.COMPLETED) {
return Result.failure(new ProductionOrderError.BatchNotCompleted(batchId));
}
switch (order.complete()) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
return unitOfWork.executeAtomically(() -> {
switch (productionOrderRepository.save(order)) {
case Result.Failure(var err) -> {
return Result.failure(new ProductionOrderError.RepositoryFailure(err.message()));
}
case Result.Success(var ignored) -> { }
}
return Result.success(order);
});
}
}

View file

@ -0,0 +1,4 @@
package de.effigenix.application.production.command;
public record CancelProductionOrderCommand(String productionOrderId, String reason) {
}

View file

@ -0,0 +1,4 @@
package de.effigenix.application.production.command;
public record CompleteProductionOrderCommand(String productionOrderId) {
}

View file

@ -22,6 +22,8 @@ import java.time.ZoneOffset;
* 7. Only RELEASED IN_PROGRESS transition allowed via startProduction(BatchId)
* 8. BatchId is set exactly once (null non-null) during startProduction()
* 9. BatchId must not already be assigned (BatchAlreadyAssigned)
* 10. Only IN_PROGRESS COMPLETED transition allowed via complete() (Batch must be COMPLETED enforced by Use Case)
* 11. Only PLANNED or RELEASED CANCELLED transition allowed via cancel()
*/
public class ProductionOrder {
@ -174,4 +176,22 @@ public class ProductionOrder {
this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC);
return Result.success(null);
}
public Result<ProductionOrderError, Void> complete() {
if (status != ProductionOrderStatus.IN_PROGRESS) {
return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, ProductionOrderStatus.COMPLETED));
}
this.status = ProductionOrderStatus.COMPLETED;
this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC);
return Result.success(null);
}
public Result<ProductionOrderError, Void> cancel() {
if (status != ProductionOrderStatus.PLANNED && status != ProductionOrderStatus.RELEASED) {
return Result.failure(new ProductionOrderError.InvalidStatusTransition(status, ProductionOrderStatus.CANCELLED));
}
this.status = ProductionOrderStatus.CANCELLED;
this.updatedAt = OffsetDateTime.now(ZoneOffset.UTC);
return Result.success(null);
}
}

View file

@ -45,6 +45,11 @@ public sealed interface ProductionOrderError {
@Override public String message() { return "Production order already has batch '" + batchId.value() + "' assigned"; }
}
record BatchNotCompleted(BatchId batchId) implements ProductionOrderError {
@Override public String code() { return "PRODUCTION_ORDER_BATCH_NOT_COMPLETED"; }
@Override public String message() { return "Batch '" + batchId.value() + "' is not in COMPLETED status"; }
}
record ValidationFailure(String message) implements ProductionOrderError {
@Override public String code() { return "PRODUCTION_ORDER_VALIDATION_ERROR"; }
}

View file

@ -4,6 +4,8 @@ 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.CancelProductionOrder;
import de.effigenix.application.production.CompleteProductionOrder;
import de.effigenix.application.production.CreateProductionOrder;
import de.effigenix.application.production.ReleaseProductionOrder;
import de.effigenix.application.production.StartProductionOrder;
@ -160,4 +162,19 @@ public class ProductionUseCaseConfiguration {
UnitOfWork unitOfWork) {
return new StartProductionOrder(productionOrderRepository, batchRepository, authorizationPort, unitOfWork);
}
@Bean
public CompleteProductionOrder completeProductionOrder(ProductionOrderRepository productionOrderRepository,
BatchRepository batchRepository,
AuthorizationPort authorizationPort,
UnitOfWork unitOfWork) {
return new CompleteProductionOrder(productionOrderRepository, batchRepository, authorizationPort, unitOfWork);
}
@Bean
public CancelProductionOrder cancelProductionOrder(ProductionOrderRepository productionOrderRepository,
AuthorizationPort authorizationPort,
UnitOfWork unitOfWork) {
return new CancelProductionOrder(productionOrderRepository, authorizationPort, unitOfWork);
}
}

View file

@ -1,12 +1,17 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.CancelProductionOrder;
import de.effigenix.application.production.CompleteProductionOrder;
import de.effigenix.application.production.CreateProductionOrder;
import de.effigenix.application.production.ReleaseProductionOrder;
import de.effigenix.application.production.StartProductionOrder;
import de.effigenix.application.production.command.CancelProductionOrderCommand;
import de.effigenix.application.production.command.CompleteProductionOrderCommand;
import de.effigenix.application.production.command.CreateProductionOrderCommand;
import de.effigenix.application.production.command.ReleaseProductionOrderCommand;
import de.effigenix.application.production.command.StartProductionOrderCommand;
import de.effigenix.domain.production.ProductionOrderError;
import de.effigenix.infrastructure.production.web.dto.CancelProductionOrderRequest;
import de.effigenix.infrastructure.production.web.dto.CreateProductionOrderRequest;
import de.effigenix.infrastructure.production.web.dto.ProductionOrderResponse;
import de.effigenix.infrastructure.production.web.dto.StartProductionOrderRequest;
@ -33,13 +38,19 @@ public class ProductionOrderController {
private final CreateProductionOrder createProductionOrder;
private final ReleaseProductionOrder releaseProductionOrder;
private final StartProductionOrder startProductionOrder;
private final CompleteProductionOrder completeProductionOrder;
private final CancelProductionOrder cancelProductionOrder;
public ProductionOrderController(CreateProductionOrder createProductionOrder,
ReleaseProductionOrder releaseProductionOrder,
StartProductionOrder startProductionOrder) {
StartProductionOrder startProductionOrder,
CompleteProductionOrder completeProductionOrder,
CancelProductionOrder cancelProductionOrder) {
this.createProductionOrder = createProductionOrder;
this.releaseProductionOrder = releaseProductionOrder;
this.startProductionOrder = startProductionOrder;
this.completeProductionOrder = completeProductionOrder;
this.cancelProductionOrder = cancelProductionOrder;
}
@PostMapping
@ -106,6 +117,43 @@ public class ProductionOrderController {
return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue()));
}
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')")
public ResponseEntity<ProductionOrderResponse> completeProductionOrder(
@PathVariable String id,
Authentication authentication
) {
logger.info("Completing production order: {} by actor: {}", id, authentication.getName());
var cmd = new CompleteProductionOrderCommand(id);
var result = completeProductionOrder.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new ProductionOrderDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue()));
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('PRODUCTION_ORDER_WRITE')")
public ResponseEntity<ProductionOrderResponse> cancelProductionOrder(
@PathVariable String id,
@Valid @RequestBody CancelProductionOrderRequest request,
Authentication authentication
) {
logger.info("Cancelling production order: {} by actor: {}, reason: {}", id, authentication.getName(), request.reason());
var cmd = new CancelProductionOrderCommand(id, request.reason());
var result = cancelProductionOrder.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new ProductionOrderDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(ProductionOrderResponse.from(result.unsafeGetValue()));
}
public static class ProductionOrderDomainErrorException extends RuntimeException {
private final ProductionOrderError error;

View file

@ -0,0 +1,6 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CancelProductionOrderRequest(@NotBlank String reason) {
}

View file

@ -59,6 +59,7 @@ public final class ProductionErrorHttpStatusMapper {
case ProductionOrderError.RecipeNotActive e -> 409;
case ProductionOrderError.InvalidStatusTransition e -> 409;
case ProductionOrderError.BatchAlreadyAssigned e -> 409;
case ProductionOrderError.BatchNotCompleted e -> 409;
case ProductionOrderError.ValidationFailure e -> 400;
case ProductionOrderError.Unauthorized e -> 403;
case ProductionOrderError.RepositoryFailure e -> 500;