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

feat(production): Vorwärts-Tracing für Rückruf-Szenario (US-P18)

BFS-Traversierung über Chargen-Genealogie (batch_consumptions.input_batch_id)
mit Cycle-Detection und Max-Depth-Guard. REST-Endpoint GET /{id}/trace-forward
liefert flache Liste mit Tiefenangabe für betroffene Endprodukt-Chargen.
This commit is contained in:
Sebastian Frick 2026-02-26 09:24:49 +01:00
parent 973c33d78f
commit ddb674d618
14 changed files with 822 additions and 1 deletions

View file

@ -0,0 +1,28 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.TraceBatchForwardCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import java.util.List;
public class TraceBatchForward {
private final BatchTraceabilityService traceabilityService;
private final AuthorizationPort authorizationPort;
public TraceBatchForward(BatchTraceabilityService traceabilityService, AuthorizationPort authorizationPort) {
this.traceabilityService = traceabilityService;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, List<TracedBatch>> execute(TraceBatchForwardCommand command, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
return traceabilityService.traceForward(BatchId.of(command.batchId()));
}
}

View file

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

View file

@ -29,5 +29,7 @@ public interface BatchRepository {
Result<RepositoryError, List<Batch>> findByRecipeIdsSummary(List<RecipeId> recipeIds);
Result<RepositoryError, List<Batch>> findByInputBatchId(BatchId inputBatchId);
Result<RepositoryError, Void> save(Batch batch);
}

View file

@ -0,0 +1,67 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class BatchTraceabilityService {
static final int DEFAULT_MAX_DEPTH = 10;
private final BatchRepository batchRepository;
public BatchTraceabilityService(BatchRepository batchRepository) {
this.batchRepository = batchRepository;
}
public Result<BatchError, List<TracedBatch>> traceForward(BatchId startBatchId) {
return traceForward(startBatchId, DEFAULT_MAX_DEPTH);
}
public Result<BatchError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth) {
switch (batchRepository.findById(startBatchId)) {
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(startBatchId));
}
}
}
List<TracedBatch> result = new ArrayList<>();
Set<String> visited = new HashSet<>();
visited.add(startBatchId.value());
record BfsEntry(BatchId batchId, int depth) {}
var queue = new ArrayDeque<BfsEntry>();
queue.add(new BfsEntry(startBatchId, 0));
while (!queue.isEmpty()) {
var entry = queue.poll();
if (entry.depth() >= maxDepth) {
continue;
}
switch (batchRepository.findByInputBatchId(entry.batchId())) {
case Result.Failure(var err) ->
{ return Result.failure(new BatchError.RepositoryFailure(err.message())); }
case Result.Success(var children) -> {
int childDepth = entry.depth() + 1;
for (Batch child : children) {
if (visited.add(child.id().value())) {
result.add(new TracedBatch(child, childDepth));
queue.add(new BfsEntry(child.id(), childDepth));
}
}
}
}
}
return Result.success(result);
}
}

View file

@ -0,0 +1,4 @@
package de.effigenix.domain.production;
public record TracedBatch(Batch batch, int depth) {
}

View file

@ -15,6 +15,7 @@ import de.effigenix.application.production.StartProductionOrder;
import de.effigenix.application.production.CancelBatch;
import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.TraceBatchForward;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
import de.effigenix.application.production.ListBatches;
@ -28,6 +29,7 @@ import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.BatchNumberGenerator;
import de.effigenix.domain.production.BatchRepository;
import de.effigenix.domain.production.BatchTraceabilityService;
import de.effigenix.domain.production.ProductionOrderRepository;
import de.effigenix.domain.production.RecipeRepository;
import de.effigenix.shared.persistence.UnitOfWork;
@ -142,6 +144,17 @@ public class ProductionUseCaseConfiguration {
return new CancelBatch(batchRepository, authorizationPort, unitOfWork);
}
@Bean
public BatchTraceabilityService batchTraceabilityService(BatchRepository batchRepository) {
return new BatchTraceabilityService(batchRepository);
}
@Bean
public TraceBatchForward traceBatchForward(BatchTraceabilityService batchTraceabilityService,
AuthorizationPort authorizationPort) {
return new TraceBatchForward(batchTraceabilityService, authorizationPort);
}
@Bean
public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository,
RecipeRepository recipeRepository,

View file

@ -192,6 +192,24 @@ public class JdbcBatchRepository implements BatchRepository {
}
}
@Override
public Result<RepositoryError, List<Batch>> findByInputBatchId(BatchId inputBatchId) {
try {
var batches = jdbc.sql("""
SELECT b.* FROM batches b
JOIN batch_consumptions bc ON b.id = bc.batch_id
WHERE bc.input_batch_id = :inputBatchId
""")
.param("inputBatchId", inputBatchId.value())
.query(this::mapBatchRow)
.list();
return Result.success(batches);
} catch (Exception e) {
logger.trace("Database error in findByInputBatchId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
try {

View file

@ -8,11 +8,13 @@ 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.TraceBatchForward;
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;
import de.effigenix.application.production.command.StartBatchCommand;
import de.effigenix.application.production.command.TraceBatchForwardCommand;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.BatchId;
import de.effigenix.domain.production.BatchNumber;
@ -20,6 +22,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.TraceBatchForwardResponse;
import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
import de.effigenix.infrastructure.production.web.dto.PlanBatchRequest;
@ -56,11 +59,12 @@ public class BatchController {
private final RecordConsumption recordConsumption;
private final CompleteBatch completeBatch;
private final CancelBatch cancelBatch;
private final TraceBatchForward traceBatchForward;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption, CompleteBatch completeBatch,
CancelBatch cancelBatch) {
CancelBatch cancelBatch, TraceBatchForward traceBatchForward) {
this.planBatch = planBatch;
this.getBatch = getBatch;
this.listBatches = listBatches;
@ -69,6 +73,7 @@ public class BatchController {
this.recordConsumption = recordConsumption;
this.completeBatch = completeBatch;
this.cancelBatch = cancelBatch;
this.traceBatchForward = traceBatchForward;
}
@GetMapping("/{id}")
@ -244,6 +249,23 @@ public class BatchController {
return ResponseEntity.ok(BatchResponse.from(result.unsafeGetValue()));
}
@GetMapping("/{id}/trace-forward")
@PreAuthorize("hasAuthority('BATCH_READ')")
public ResponseEntity<TraceBatchForwardResponse> traceForward(
@PathVariable("id") String id,
Authentication authentication
) {
var actorId = ActorId.of(authentication.getName());
var cmd = new TraceBatchForwardCommand(id);
var result = traceBatchForward.execute(cmd, actorId);
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(TraceBatchForwardResponse.from(id, result.unsafeGetValue()));
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('BATCH_CANCEL')")
public ResponseEntity<BatchResponse> cancelBatch(

View file

@ -0,0 +1,37 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.TracedBatch;
import java.util.List;
public record TraceBatchForwardResponse(
String originBatchId,
List<TracedBatchResponse> tracedBatches,
int totalCount
) {
public record TracedBatchResponse(
String id,
String batchNumber,
String recipeId,
String status,
int depth
) {
public static TracedBatchResponse from(TracedBatch traced) {
var batch = traced.batch();
return new TracedBatchResponse(
batch.id().value(),
batch.batchNumber().value(),
batch.recipeId().value(),
batch.status().name(),
traced.depth()
);
}
}
public static TraceBatchForwardResponse from(String originBatchId, List<TracedBatch> tracedBatches) {
var responses = tracedBatches.stream()
.map(TracedBatchResponse::from)
.toList();
return new TraceBatchForwardResponse(originBatchId, responses, responses.size());
}
}

View file

@ -72,6 +72,11 @@ public class StubBatchRepository implements BatchRepository {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Batch>> findByInputBatchId(BatchId inputBatchId) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
return Result.failure(STUB_ERROR);