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:
parent
14b59722f7
commit
72d59b4948
17 changed files with 1216 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record CancelProductionOrderCommand(String productionOrderId, String reason) {
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package de.effigenix.application.production.command;
|
||||
|
||||
public record CompleteProductionOrderCommand(String productionOrderId) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package de.effigenix.infrastructure.production.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CancelProductionOrderRequest(@NotBlank String reason) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue