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>> 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);

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 {
@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")
void fuzzTraceForward(FuzzedDataProvider data) {
int nodeCount = data.consumeInt(1, 30);
@ -149,6 +195,41 @@ class BatchTraceabilityServiceFuzzTest {
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 ---
@Override public Result<RepositoryError, List<Batch>> findAll() { throw new UnsupportedOperationException(); }

View file

@ -157,4 +157,110 @@ class BatchTraceabilityServiceTest {
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 ====================
@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 ----
public static ChainBuilder createProductionOrder() {
@ -259,6 +287,11 @@ public final class ProductionScenario {
.pause(1, 2)
.exec(listProductionOrdersByStatus())
.pause(1, 2)
// Tracing-Szenarien
.exec(traceForward())
.pause(1, 2)
.exec(traceBackward())
.pause(1, 2)
// Nochmal Chargen-Liste prüfen
.exec(listBatches());
}
@ -271,11 +304,13 @@ public final class ProductionScenario {
.exec(AuthenticationScenario.login("admin", "admin123"))
.repeat(15).on(
randomSwitch().on(
percent(30.0).then(listRecipes()),
percent(20.0).then(getRandomRecipe()),
percent(20.0).then(listBatches()),
percent(15.0).then(listProductionOrders()),
percent(15.0).then(listProductionOrdersByStatus())
percent(25.0).then(listRecipes()),
percent(15.0).then(getRandomRecipe()),
percent(15.0).then(listBatches()),
percent(10.0).then(listProductionOrders()),
percent(10.0).then(listProductionOrdersByStatus()),
percent(12.5).then(traceForward()),
percent(12.5).then(traceBackward())
).pause(1, 3)
);
}

View file

@ -127,6 +127,10 @@ public class FullWorkloadSimulation extends Simulation {
details("Inventur starten").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
details("Produktionsaufträge auflisten").responseTime().mean().lt(35),
details("Produktionsaufträge nach Status").responseTime().mean().lt(35)