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

feat(production): Rückwärts-Tracing für Herkunftsnachweis (US-P19)

Spiegelt die bestehende traceForward-Architektur mit invertierter
SQL-JOIN-Richtung, um von einer Endprodukt-Charge alle verwendeten
Rohstoff-Chargen zu ermitteln (Herkunftsnachweis).
This commit is contained in:
Sebastian Frick 2026-02-26 20:13:04 +01:00
parent 252f48d52b
commit 6996a301f9
15 changed files with 632 additions and 6 deletions

View file

@ -0,0 +1,28 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.TraceBatchBackwardCommand;
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 TraceBatchBackward {
private final BatchTraceabilityService traceabilityService;
private final AuthorizationPort authorizationPort;
public TraceBatchBackward(BatchTraceabilityService traceabilityService, AuthorizationPort authorizationPort) {
this.traceabilityService = traceabilityService;
this.authorizationPort = authorizationPort;
}
public Result<BatchError, List<TracedBatch>> execute(TraceBatchBackwardCommand command, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.BATCH_READ)) {
return Result.failure(new BatchError.Unauthorized("Not authorized to read batches"));
}
return traceabilityService.traceBackward(BatchId.of(command.batchId()));
}
}

View file

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

View file

@ -33,5 +33,7 @@ public interface BatchRepository {
Result<RepositoryError, List<TracedBatch>> traceForward(BatchId startBatchId, int maxDepth);
Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth);
Result<RepositoryError, Void> save(Batch batch);
}

View file

@ -40,4 +40,31 @@ public class BatchTraceabilityService {
Result.success(traced);
};
}
public Result<BatchError, List<TracedBatch>> traceBackward(BatchId startBatchId) {
return traceBackward(startBatchId, DEFAULT_MAX_DEPTH);
}
public Result<BatchError, List<TracedBatch>> traceBackward(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));
}
}
}
if (maxDepth <= 0) {
return Result.success(List.of());
}
return switch (batchRepository.traceBackward(startBatchId, maxDepth)) {
case Result.Failure(var err) ->
Result.failure(new BatchError.RepositoryFailure(err.message()));
case Result.Success(var traced) ->
Result.success(traced);
};
}
}

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.TraceBatchBackward;
import de.effigenix.application.production.TraceBatchForward;
import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch;
@ -155,6 +156,12 @@ public class ProductionUseCaseConfiguration {
return new TraceBatchForward(batchTraceabilityService, authorizationPort);
}
@Bean
public TraceBatchBackward traceBatchBackward(BatchTraceabilityService batchTraceabilityService,
AuthorizationPort authorizationPort) {
return new TraceBatchBackward(batchTraceabilityService, authorizationPort);
}
@Bean
public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository,
RecipeRepository recipeRepository,

View file

@ -250,6 +250,44 @@ public class JdbcBatchRepository implements BatchRepository {
}
}
@Override
public Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth) {
try {
String startId = startBatchId.value();
var results = new ArrayList<TracedBatch>();
var visited = new HashSet<String>();
visited.add(startId);
var currentLevel = List.of(startId);
for (int depth = 1; depth <= maxDepth && !currentLevel.isEmpty(); depth++) {
int currentDepth = depth;
var parents = jdbc.sql("""
SELECT DISTINCT b.*
FROM batches b
JOIN batch_consumptions bc ON b.id = bc.input_batch_id
WHERE bc.batch_id IN (:childIds)
""")
.param("childIds", currentLevel)
.query(this::mapBatchRow)
.list();
var nextLevel = new ArrayList<String>();
for (Batch parent : parents) {
if (visited.add(parent.id().value())) {
results.add(new TracedBatch(parent, currentDepth));
nextLevel.add(parent.id().value());
}
}
currentLevel = nextLevel;
}
return Result.success(results);
} catch (Exception e) {
logger.warn("Database error in traceBackward", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
try {

View file

@ -8,12 +8,14 @@ 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.TraceBatchBackward;
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.TraceBatchBackwardCommand;
import de.effigenix.application.production.command.TraceBatchForwardCommand;
import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.BatchId;
@ -22,6 +24,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.TraceBatchBackwardResponse;
import de.effigenix.infrastructure.production.web.dto.TraceBatchForwardResponse;
import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
@ -60,11 +63,13 @@ public class BatchController {
private final CompleteBatch completeBatch;
private final CancelBatch cancelBatch;
private final TraceBatchForward traceBatchForward;
private final TraceBatchBackward traceBatchBackward;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption, CompleteBatch completeBatch,
CancelBatch cancelBatch, TraceBatchForward traceBatchForward) {
CancelBatch cancelBatch, TraceBatchForward traceBatchForward,
TraceBatchBackward traceBatchBackward) {
this.planBatch = planBatch;
this.getBatch = getBatch;
this.listBatches = listBatches;
@ -74,6 +79,7 @@ public class BatchController {
this.completeBatch = completeBatch;
this.cancelBatch = cancelBatch;
this.traceBatchForward = traceBatchForward;
this.traceBatchBackward = traceBatchBackward;
}
@GetMapping("/{id}")
@ -266,6 +272,23 @@ public class BatchController {
return ResponseEntity.ok(TraceBatchForwardResponse.from(id, result.unsafeGetValue()));
}
@GetMapping("/{id}/trace-backward")
@PreAuthorize("hasAuthority('BATCH_READ')")
public ResponseEntity<TraceBatchBackwardResponse> traceBackward(
@PathVariable("id") String id,
Authentication authentication
) {
var actorId = ActorId.of(authentication.getName());
var cmd = new TraceBatchBackwardCommand(id);
var result = traceBatchBackward.execute(cmd, actorId);
if (result.isFailure()) {
throw new BatchDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(TraceBatchBackwardResponse.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 TraceBatchBackwardResponse(
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 TraceBatchBackwardResponse from(String originBatchId, List<TracedBatch> tracedBatches) {
var responses = tracedBatches.stream()
.map(TracedBatchResponse::from)
.toList();
return new TraceBatchBackwardResponse(originBatchId, responses, responses.size());
}
}

View file

@ -83,6 +83,11 @@ public class StubBatchRepository implements BatchRepository {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, Void> save(Batch batch) {
return Result.failure(STUB_ERROR);