mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 14:09:34 +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>> traceBackward(BatchId startBatchId, int maxDepth);
|
||||
|
||||
Result<RepositoryError, Void> save(Batch batch);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
@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(); }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue