1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +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>> traceForward(BatchId startBatchId, int maxDepth);
Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth);
Result<RepositoryError, Void> save(Batch batch); Result<RepositoryError, Void> save(Batch batch);
} }

View file

@ -40,4 +40,31 @@ public class BatchTraceabilityService {
Result.success(traced); 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.CancelBatch;
import de.effigenix.application.production.CompleteBatch; import de.effigenix.application.production.CompleteBatch;
import de.effigenix.application.production.CreateRecipe; import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.TraceBatchBackward;
import de.effigenix.application.production.TraceBatchForward; import de.effigenix.application.production.TraceBatchForward;
import de.effigenix.application.production.FindBatchByNumber; import de.effigenix.application.production.FindBatchByNumber;
import de.effigenix.application.production.GetBatch; import de.effigenix.application.production.GetBatch;
@ -155,6 +156,12 @@ public class ProductionUseCaseConfiguration {
return new TraceBatchForward(batchTraceabilityService, authorizationPort); return new TraceBatchForward(batchTraceabilityService, authorizationPort);
} }
@Bean
public TraceBatchBackward traceBatchBackward(BatchTraceabilityService batchTraceabilityService,
AuthorizationPort authorizationPort) {
return new TraceBatchBackward(batchTraceabilityService, authorizationPort);
}
@Bean @Bean
public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository, public CreateProductionOrder createProductionOrder(ProductionOrderRepository productionOrderRepository,
RecipeRepository recipeRepository, 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 @Override
public Result<RepositoryError, Void> save(Batch batch) { public Result<RepositoryError, Void> save(Batch batch) {
try { try {

View file

@ -8,12 +8,14 @@ import de.effigenix.application.production.ListBatches;
import de.effigenix.application.production.PlanBatch; import de.effigenix.application.production.PlanBatch;
import de.effigenix.application.production.RecordConsumption; import de.effigenix.application.production.RecordConsumption;
import de.effigenix.application.production.StartBatch; import de.effigenix.application.production.StartBatch;
import de.effigenix.application.production.TraceBatchBackward;
import de.effigenix.application.production.TraceBatchForward; import de.effigenix.application.production.TraceBatchForward;
import de.effigenix.application.production.command.CancelBatchCommand; import de.effigenix.application.production.command.CancelBatchCommand;
import de.effigenix.application.production.command.CompleteBatchCommand; import de.effigenix.application.production.command.CompleteBatchCommand;
import de.effigenix.application.production.command.PlanBatchCommand; import de.effigenix.application.production.command.PlanBatchCommand;
import de.effigenix.application.production.command.RecordConsumptionCommand; import de.effigenix.application.production.command.RecordConsumptionCommand;
import de.effigenix.application.production.command.StartBatchCommand; import de.effigenix.application.production.command.StartBatchCommand;
import de.effigenix.application.production.command.TraceBatchBackwardCommand;
import de.effigenix.application.production.command.TraceBatchForwardCommand; import de.effigenix.application.production.command.TraceBatchForwardCommand;
import de.effigenix.domain.production.BatchError; import de.effigenix.domain.production.BatchError;
import de.effigenix.domain.production.BatchId; 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.BatchResponse;
import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse; import de.effigenix.infrastructure.production.web.dto.BatchSummaryResponse;
import de.effigenix.infrastructure.production.web.dto.ConsumptionResponse; 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.TraceBatchForwardResponse;
import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest; import de.effigenix.infrastructure.production.web.dto.CancelBatchRequest;
import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest; import de.effigenix.infrastructure.production.web.dto.CompleteBatchRequest;
@ -60,11 +63,13 @@ public class BatchController {
private final CompleteBatch completeBatch; private final CompleteBatch completeBatch;
private final CancelBatch cancelBatch; private final CancelBatch cancelBatch;
private final TraceBatchForward traceBatchForward; private final TraceBatchForward traceBatchForward;
private final TraceBatchBackward traceBatchBackward;
public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches, public BatchController(PlanBatch planBatch, GetBatch getBatch, ListBatches listBatches,
FindBatchByNumber findBatchByNumber, StartBatch startBatch, FindBatchByNumber findBatchByNumber, StartBatch startBatch,
RecordConsumption recordConsumption, CompleteBatch completeBatch, RecordConsumption recordConsumption, CompleteBatch completeBatch,
CancelBatch cancelBatch, TraceBatchForward traceBatchForward) { CancelBatch cancelBatch, TraceBatchForward traceBatchForward,
TraceBatchBackward traceBatchBackward) {
this.planBatch = planBatch; this.planBatch = planBatch;
this.getBatch = getBatch; this.getBatch = getBatch;
this.listBatches = listBatches; this.listBatches = listBatches;
@ -74,6 +79,7 @@ public class BatchController {
this.completeBatch = completeBatch; this.completeBatch = completeBatch;
this.cancelBatch = cancelBatch; this.cancelBatch = cancelBatch;
this.traceBatchForward = traceBatchForward; this.traceBatchForward = traceBatchForward;
this.traceBatchBackward = traceBatchBackward;
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@ -266,6 +272,23 @@ public class BatchController {
return ResponseEntity.ok(TraceBatchForwardResponse.from(id, result.unsafeGetValue())); 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") @PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('BATCH_CANCEL')") @PreAuthorize("hasAuthority('BATCH_CANCEL')")
public ResponseEntity<BatchResponse> cancelBatch( 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); return Result.failure(STUB_ERROR);
} }
@Override
public Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth) {
return Result.failure(STUB_ERROR);
}
@Override @Override
public Result<RepositoryError, Void> save(Batch batch) { public Result<RepositoryError, Void> save(Batch batch) {
return Result.failure(STUB_ERROR); return Result.failure(STUB_ERROR);

View file

@ -0,0 +1,113 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.TraceBatchBackwardCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("TraceBatchBackward Use Case")
class TraceBatchBackwardTest {
@Mock private BatchTraceabilityService traceabilityService;
@Mock private AuthorizationPort authPort;
private TraceBatchBackward traceBatchBackward;
private ActorId performedBy;
@BeforeEach
void setUp() {
traceBatchBackward = new TraceBatchBackward(traceabilityService, authPort);
performedBy = ActorId.of("admin-user");
}
@Test
@DisplayName("should fail with Unauthorized when actor lacks permission")
void should_FailWithUnauthorized_When_ActorLacksPermission() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(false);
var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.Unauthorized.class);
verify(traceabilityService, never()).traceBackward(any());
}
@Test
@DisplayName("should delegate to BatchTraceabilityService when authorized")
void should_DelegateToService_When_Authorized() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
var tracedBatch = new TracedBatch(sampleBatch("parent-1"), 1);
when(traceabilityService.traceBackward(BatchId.of("batch-1")))
.thenReturn(Result.success(List.of(tracedBatch)));
var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1);
assertThat(result.unsafeGetValue().get(0).batch().id().value()).isEqualTo("parent-1");
verify(traceabilityService).traceBackward(BatchId.of("batch-1"));
}
@Test
@DisplayName("should return empty list when no upstream batches exist")
void should_ReturnEmptyList_When_NoUpstream() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(traceabilityService.traceBackward(BatchId.of("batch-1")))
.thenReturn(Result.success(List.of()));
var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("batch-1"), performedBy);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should propagate domain error from service")
void should_PropagateDomainError_FromService() {
when(authPort.can(performedBy, ProductionAction.BATCH_READ)).thenReturn(true);
when(traceabilityService.traceBackward(BatchId.of("nonexistent")))
.thenReturn(Result.failure(new BatchError.BatchNotFound(BatchId.of("nonexistent"))));
var result = traceBatchBackward.execute(new TraceBatchBackwardCommand("nonexistent"), performedBy);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
}
private Batch sampleBatch(String id) {
return Batch.reconstitute(
BatchId.of(id),
BatchNumber.generate(LocalDate.of(2026, 3, 1), 1),
RecipeId.of("recipe-1"),
BatchStatus.COMPLETED,
Quantity.of(new BigDecimal("100"), UnitOfMeasure.KILOGRAM).unsafeGetValue(),
null, null, null,
LocalDate.of(2026, 3, 1),
LocalDate.of(2026, 6, 1),
OffsetDateTime.now(ZoneOffset.UTC),
OffsetDateTime.now(ZoneOffset.UTC),
null, null, null,
0L, List.of()
);
}
}

View file

@ -24,6 +24,52 @@ import java.util.*;
*/ */
class BatchTraceabilityServiceFuzzTest { class BatchTraceabilityServiceFuzzTest {
@FuzzTest(maxDuration = "5m")
void fuzzTraceBackward(FuzzedDataProvider data) {
int nodeCount = data.consumeInt(1, 30);
List<String> nodeIds = new ArrayList<>();
for (int i = 0; i < nodeCount; i++) {
nodeIds.add("batch-" + i);
}
Map<String, List<String>> adjacency = new HashMap<>();
for (String nodeId : nodeIds) {
int childCount = data.consumeInt(0, Math.min(5, nodeCount));
List<String> children = new ArrayList<>();
for (int c = 0; c < childCount; c++) {
int childIdx = data.consumeInt(0, nodeCount - 1);
children.add(nodeIds.get(childIdx));
}
adjacency.put(nodeId, children);
}
var repo = new InMemoryGraphRepository(nodeIds, adjacency);
var service = new BatchTraceabilityService(repo);
String startId = nodeIds.get(data.consumeInt(0, nodeCount - 1));
int maxDepth = data.consumeInt(0, 15);
var result = service.traceBackward(BatchId.of(startId), maxDepth);
switch (result) {
case Result.Success(var traced) -> {
for (TracedBatch tb : traced) {
assert tb.depth() >= 1 && tb.depth() <= maxDepth;
}
Set<String> seen = new HashSet<>();
for (TracedBatch tb : traced) {
assert seen.add(tb.batch().id().value()) : "Duplicate batch in result";
}
for (TracedBatch tb : traced) {
assert !tb.batch().id().value().equals(startId) : "Start batch in result";
}
}
case Result.Failure(var err) -> {
assert err != null;
}
}
}
@FuzzTest(maxDuration = "5m") @FuzzTest(maxDuration = "5m")
void fuzzTraceForward(FuzzedDataProvider data) { void fuzzTraceForward(FuzzedDataProvider data) {
int nodeCount = data.consumeInt(1, 30); int nodeCount = data.consumeInt(1, 30);
@ -149,6 +195,41 @@ class BatchTraceabilityServiceFuzzTest {
return Result.success(result); return Result.success(result);
} }
@Override
public Result<RepositoryError, List<TracedBatch>> traceBackward(BatchId startBatchId, int maxDepth) {
// Invert adjacency: for traceBackward, find parents (nodes whose children include startBatchId)
Map<String, List<String>> reverseAdj = new HashMap<>();
for (var entry : adjacency.entrySet()) {
for (String child : entry.getValue()) {
reverseAdj.computeIfAbsent(child, k -> new ArrayList<>()).add(entry.getKey());
}
}
List<TracedBatch> result = new ArrayList<>();
Set<String> visited = new HashSet<>();
visited.add(startBatchId.value());
record BfsEntry(String batchId, int depth) {}
var queue = new ArrayDeque<BfsEntry>();
queue.add(new BfsEntry(startBatchId.value(), 0));
while (!queue.isEmpty()) {
var entry = queue.poll();
if (entry.depth() >= maxDepth) continue;
var parents = reverseAdj.getOrDefault(entry.batchId(), List.of());
int parentDepth = entry.depth() + 1;
for (String parentId : parents) {
if (visited.add(parentId)) {
result.add(new TracedBatch(makeBatch(parentId), parentDepth));
queue.add(new BfsEntry(parentId, parentDepth));
}
}
}
return Result.success(result);
}
// --- Remaining methods are not used by BatchTraceabilityService --- // --- Remaining methods are not used by BatchTraceabilityService ---
@Override public Result<RepositoryError, List<Batch>> findAll() { throw new UnsupportedOperationException(); } @Override public Result<RepositoryError, List<Batch>> findAll() { throw new UnsupportedOperationException(); }

View file

@ -157,4 +157,110 @@ class BatchTraceabilityServiceTest {
assertThat(result.unsafeGetValue()).isEmpty(); assertThat(result.unsafeGetValue()).isEmpty();
} }
} }
@Nested
@DisplayName("traceBackward")
class TraceBackward {
@Test
@DisplayName("should fail with BatchNotFound when start batch does not exist")
void should_FailWithBatchNotFound_When_StartBatchDoesNotExist() {
var startId = BatchId.of("nonexistent");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.empty()));
var result = service.traceBackward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.BatchNotFound.class);
verify(batchRepository, never()).traceBackward(any(), anyInt());
}
@Test
@DisplayName("should fail with RepositoryFailure when findById returns error")
void should_FailWithRepositoryFailure_When_FindByIdReturnsError() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = service.traceBackward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
verify(batchRepository, never()).traceBackward(any(), anyInt());
}
@Test
@DisplayName("should delegate to repository traceBackward when start batch exists")
void should_DelegateToRepository_When_StartBatchExists() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
var traced = List.of(
new TracedBatch(sampleBatch("parent-1"), 1),
new TracedBatch(sampleBatch("parent-2"), 1)
);
when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.success(traced));
var result = service.traceBackward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2);
verify(batchRepository).traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH);
}
@Test
@DisplayName("should pass custom maxDepth to repository")
void should_PassCustomMaxDepth_ToRepository() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.traceBackward(startId, 5)).thenReturn(Result.success(List.of()));
var result = service.traceBackward(startId, 5);
assertThat(result.isSuccess()).isTrue();
verify(batchRepository).traceBackward(startId, 5);
}
@Test
@DisplayName("should return empty list when maxDepth is 0")
void should_ReturnEmptyList_When_MaxDepthIsZero() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
var result = service.traceBackward(startId, 0);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
verify(batchRepository, never()).traceBackward(any(), anyInt());
}
@Test
@DisplayName("should fail with RepositoryFailure when traceBackward returns error")
void should_FailWithRepositoryFailure_When_TraceBackwardReturnsError() {
var startId = BatchId.of("start");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start"))));
when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("timeout")));
var result = service.traceBackward(startId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(BatchError.RepositoryFailure.class);
}
@Test
@DisplayName("should return empty list when no upstream batches exist")
void should_ReturnEmptyList_When_NoUpstreamBatches() {
var startId = BatchId.of("start-batch");
when(batchRepository.findById(startId)).thenReturn(Result.success(Optional.of(sampleBatch("start-batch"))));
when(batchRepository.traceBackward(startId, BatchTraceabilityService.DEFAULT_MAX_DEPTH))
.thenReturn(Result.success(List.of()));
var result = service.traceBackward(startId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
}
} }

View file

@ -781,6 +781,122 @@ class BatchControllerIntegrationTest extends AbstractIntegrationTest {
} }
} }
// ==================== GET /api/production/batches/{id}/trace-backward Rückwärts-Tracing ====================
@Nested
@DisplayName("GET /api/production/batches/{id}/trace-backward Rückwärts-Tracing")
class TraceBackwardEndpoint {
private String traceToken;
@BeforeEach
void setUpTraceToken() {
traceToken = generateToken(UUID.randomUUID().toString(), "trace.admin",
"BATCH_WRITE,BATCH_READ,RECIPE_WRITE,RECIPE_READ");
}
@Test
@DisplayName("Kein Upstream → 200, leere Liste")
void traceBackward_noUpstream_returnsEmptyList() throws Exception {
String batchId = createPlannedBatchWith(traceToken);
mockMvc.perform(get("/api/production/batches/{id}/trace-backward", batchId)
.header("Authorization", "Bearer " + traceToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.originBatchId").value(batchId))
.andExpect(jsonPath("$.tracedBatches").isArray())
.andExpect(jsonPath("$.tracedBatches").isEmpty())
.andExpect(jsonPath("$.totalCount").value(0));
}
@Test
@DisplayName("Single-Level Kette → 200, depth=1, upstream Chargen")
void traceBackward_singleLevel_returnsDepth1() throws Exception {
// Rohstoff-Charge (Input)
String inputBatchId = createPlannedBatchWith(traceToken);
// Endprodukt-Charge verbraucht die Input-Charge
String outputBatchId = createBatchWithConsumption(traceToken, inputBatchId);
mockMvc.perform(get("/api/production/batches/{id}/trace-backward", outputBatchId)
.header("Authorization", "Bearer " + traceToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.originBatchId").value(outputBatchId))
.andExpect(jsonPath("$.totalCount").value(1))
.andExpect(jsonPath("$.tracedBatches[0].id").value(inputBatchId))
.andExpect(jsonPath("$.tracedBatches[0].depth").value(1))
.andExpect(jsonPath("$.tracedBatches[0].batchNumber").isNotEmpty())
.andExpect(jsonPath("$.tracedBatches[0].status").isNotEmpty());
}
@Test
@DisplayName("Batch nicht gefunden → 404")
void traceBackward_notFound_returns404() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + traceToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND"));
}
@Test
@DisplayName("Ohne Token → 401")
void traceBackward_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne BATCH_READ → 403")
void traceBackward_withoutPermission_returns403() throws Exception {
mockMvc.perform(get("/api/production/batches/{id}/trace-backward", UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
private String createPlannedBatchWith(String token) throws Exception {
String recipeId = createActiveRecipeWith(token);
var planRequest = new PlanBatchRequest(
recipeId, "100", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
}
private String createBatchWithConsumption(String token, String inputBatchId) throws Exception {
String recipeId = createActiveRecipeWith(token);
var planRequest = new PlanBatchRequest(
recipeId, "50", "KILOGRAM", PRODUCTION_DATE, BEST_BEFORE_DATE);
var planResult = mockMvc.perform(post("/api/production/batches")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(planRequest)))
.andExpect(status().isCreated())
.andReturn();
String batchId = objectMapper.readTree(planResult.getResponse().getContentAsString()).get("id").asText();
// Start production
mockMvc.perform(post("/api/production/batches/{id}/start", batchId)
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
// Record consumption with inputBatchId
String consumptionJson = """
{"inputBatchId": "%s", "articleId": "%s", "quantityUsed": "10.0", "quantityUnit": "KILOGRAM"}
""".formatted(inputBatchId, UUID.randomUUID().toString());
mockMvc.perform(post("/api/production/batches/{id}/consumptions", batchId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(consumptionJson))
.andExpect(status().isCreated());
return batchId;
}
}
// ==================== POST /api/production/batches/{id}/start ungültige Übergänge ==================== // ==================== POST /api/production/batches/{id}/start ungültige Übergänge ====================
@Nested @Nested

View file

@ -89,6 +89,34 @@ public final class ProductionScenario {
); );
} }
// ---- Tracing ----
public static ChainBuilder traceForward() {
return exec(session -> {
var ids = LoadTestDataSeeder.batchIds();
String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
return session.set("traceBatchId", id);
}).exec(
http("Charge vorwärts tracen")
.get("/api/production/batches/#{traceBatchId}/trace-forward")
.header("Authorization", "Bearer #{accessToken}")
.check(status().in(200, 404))
);
}
public static ChainBuilder traceBackward() {
return exec(session -> {
var ids = LoadTestDataSeeder.batchIds();
String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size()));
return session.set("traceBatchId", id);
}).exec(
http("Charge rückwärts tracen")
.get("/api/production/batches/#{traceBatchId}/trace-backward")
.header("Authorization", "Bearer #{accessToken}")
.check(status().in(200, 404))
);
}
// ---- Produktionsaufträge ---- // ---- Produktionsaufträge ----
public static ChainBuilder createProductionOrder() { public static ChainBuilder createProductionOrder() {
@ -259,6 +287,11 @@ public final class ProductionScenario {
.pause(1, 2) .pause(1, 2)
.exec(listProductionOrdersByStatus()) .exec(listProductionOrdersByStatus())
.pause(1, 2) .pause(1, 2)
// Tracing-Szenarien
.exec(traceForward())
.pause(1, 2)
.exec(traceBackward())
.pause(1, 2)
// Nochmal Chargen-Liste prüfen // Nochmal Chargen-Liste prüfen
.exec(listBatches()); .exec(listBatches());
} }
@ -271,11 +304,13 @@ public final class ProductionScenario {
.exec(AuthenticationScenario.login("admin", "admin123")) .exec(AuthenticationScenario.login("admin", "admin123"))
.repeat(15).on( .repeat(15).on(
randomSwitch().on( randomSwitch().on(
percent(30.0).then(listRecipes()), percent(25.0).then(listRecipes()),
percent(20.0).then(getRandomRecipe()), percent(15.0).then(getRandomRecipe()),
percent(20.0).then(listBatches()), percent(15.0).then(listBatches()),
percent(15.0).then(listProductionOrders()), percent(10.0).then(listProductionOrders()),
percent(15.0).then(listProductionOrdersByStatus()) percent(10.0).then(listProductionOrdersByStatus()),
percent(12.5).then(traceForward()),
percent(12.5).then(traceBackward())
).pause(1, 3) ).pause(1, 3)
); );
} }

View file

@ -127,6 +127,10 @@ public class FullWorkloadSimulation extends Simulation {
details("Inventur starten").responseTime().mean().lt(50), details("Inventur starten").responseTime().mean().lt(50),
details("Ist-Menge erfassen").responseTime().mean().lt(50), details("Ist-Menge erfassen").responseTime().mean().lt(50),
// Tracing: BFS-Traversierung, mean < 50ms
details("Charge vorwärts tracen").responseTime().mean().lt(50),
details("Charge rückwärts tracen").responseTime().mean().lt(50),
// Produktionsaufträge-Listen: mean < 35ms // Produktionsaufträge-Listen: mean < 35ms
details("Produktionsaufträge auflisten").responseTime().mean().lt(35), details("Produktionsaufträge auflisten").responseTime().mean().lt(35),
details("Produktionsaufträge nach Status").responseTime().mean().lt(35) details("Produktionsaufträge nach Status").responseTime().mean().lt(35)