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:
parent
252f48d52b
commit
6996a301f9
15 changed files with 632 additions and 6 deletions
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package de.effigenix.application.production.command;
|
||||||
|
|
||||||
|
public record TraceBatchBackwardCommand(String batchId) {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue