From 0e5d8f70258e788bfa782e63e7659b84d0cee2f6 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Wed, 25 Feb 2026 08:37:46 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Bestandsbewegungen=20abfrage?= =?UTF-8?q?n=20mit=20Zeitraum-=20und=20Chargen-Filter=20=E2=80=93=20Issue?= =?UTF-8?q?=20#16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erweitert die StockMovement-Abfrage um batchReference- und from/to-Filter mit Filter-Priorität stockId > articleId > batchReference > movementType > from/to. Inkl. DB-Index auf batch_id, Unit-/Integrationstests und Lasttest-Szenarien. --- .../inventory/ListStockMovements.java | 22 +- .../domain/inventory/StockMovementError.java | 5 + .../inventory/StockMovementRepository.java | 5 + .../JpaStockMovementRepository.java | 27 +++ .../StockMovementJpaRepository.java | 5 + .../controller/StockMovementController.java | 8 +- .../InventoryErrorHttpStatusMapper.java | 1 + ...-add-batch-id-index-to-stock-movements.xml | 14 ++ .../db/changelog/db.changelog-master.xml | 1 + .../inventory/ListStockMovementsTest.java | 211 ++++++++++++++++-- ...tockMovementControllerIntegrationTest.java | 104 +++++++++ .../loadtest/scenario/InventoryScenario.java | 37 ++- .../simulation/FullWorkloadSimulation.java | 2 + 13 files changed, 416 insertions(+), 26 deletions(-) create mode 100644 backend/src/main/resources/db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java b/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java index 38a4890..b441a65 100644 --- a/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java +++ b/backend/src/main/java/de/effigenix/application/inventory/ListStockMovements.java @@ -13,6 +13,7 @@ import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.AuthorizationPort; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; @Transactional(readOnly = true) @@ -28,17 +29,18 @@ public class ListStockMovements { /** * Lists stock movements with optional filtering. - * Filter priority (only one filter applied): stockId > articleId > movementType. + * Filter priority (only one filter applied): stockId > articleId > batchReference > movementType > from/to. * If multiple filters are provided, only the highest-priority filter is used. */ public Result> execute( - String stockId, String articleId, String movementType, ActorId performedBy) { + String stockId, String articleId, String movementType, + String batchReference, Instant from, Instant to, + ActorId performedBy) { if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) { return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements")); } - if (stockId != null) { StockId sid; try { @@ -59,6 +61,10 @@ public class ListStockMovements { return mapResult(stockMovementRepository.findAllByArticleId(aid)); } + if (batchReference != null) { + return mapResult(stockMovementRepository.findAllByBatchId(batchReference)); + } + if (movementType != null) { MovementType type; try { @@ -70,6 +76,16 @@ public class ListStockMovements { return mapResult(stockMovementRepository.findAllByMovementType(type)); } + if (from != null || to != null) { + if (from != null && to != null && from.isAfter(to)) { + return Result.failure(new StockMovementError.InvalidDateRange( + "'from' must not be after 'to'")); + } + Instant effectiveFrom = from != null ? from : Instant.MIN; + Instant effectiveTo = to != null ? to : Instant.MAX; + return mapResult(stockMovementRepository.findAllByPerformedAtBetween(effectiveFrom, effectiveTo)); + } + return mapResult(stockMovementRepository.findAll()); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java index 6234f5c..394525b 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementError.java @@ -64,6 +64,11 @@ public sealed interface StockMovementError { @Override public String code() { return "UNAUTHORIZED"; } } + record InvalidDateRange(String reason) implements StockMovementError { + @Override public String code() { return "INVALID_DATE_RANGE"; } + @Override public String message() { return "Invalid date range: " + reason; } + } + record RepositoryFailure(String message) implements StockMovementError { @Override public String code() { return "REPOSITORY_ERROR"; } } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java index be3da4f..8f5f48f 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockMovementRepository.java @@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -19,5 +20,9 @@ public interface StockMovementRepository { Result> findAllByMovementType(MovementType movementType); + Result> findAllByBatchId(String batchId); + + Result> findAllByPerformedAtBetween(Instant from, Instant to); + Result save(StockMovement stockMovement); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java index 5aea26c..aaa04ea 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JpaStockMovementRepository.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -93,6 +94,32 @@ public class JpaStockMovementRepository implements StockMovementRepository { } } + @Override + public Result> findAllByBatchId(String batchId) { + try { + List result = jpaRepository.findAllByBatchId(batchId).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAllByBatchId", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + + @Override + public Result> findAllByPerformedAtBetween(Instant from, Instant to) { + try { + List result = jpaRepository.findAllByPerformedAtBetween(from, to).stream() + .map(mapper::toDomain) + .toList(); + return Result.success(result); + } catch (Exception e) { + logger.warn("Database error in findAllByPerformedAtBetween", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override @Transactional public Result save(StockMovement stockMovement) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java index 094167b..1293544 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/StockMovementJpaRepository.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.persistence.repository; import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.Instant; import java.util.List; public interface StockMovementJpaRepository extends JpaRepository { @@ -12,4 +13,8 @@ public interface StockMovementJpaRepository extends JpaRepository findAllByArticleId(String articleId); List findAllByMovementType(String movementType); + + List findAllByBatchId(String batchId); + + List findAllByPerformedAtBetween(Instant from, Instant to); } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java index 9393383..d2d0eb5 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockMovementController.java @@ -14,12 +14,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.util.List; @RestController @@ -73,14 +75,18 @@ public class StockMovementController { @GetMapping @PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')") @Operation(summary = "List stock movements", - description = "Filter priority (only one filter applied): stockId > articleId > movementType") + description = "Filter priority (only one filter applied): stockId > articleId > batchReference > movementType > from/to") public ResponseEntity> listMovements( @RequestParam(required = false) String stockId, @RequestParam(required = false) String articleId, @RequestParam(required = false) String movementType, + @RequestParam(required = false) String batchReference, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to, Authentication authentication ) { var result = listStockMovements.execute(stockId, articleId, movementType, + batchReference, from, to, ActorId.of(authentication.getName())); if (result.isFailure()) { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java index a420a7c..f6df127 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper { case StockMovementError.ReasonRequired e -> 400; case StockMovementError.ReferenceDocumentRequired e -> 400; case StockMovementError.InvalidPerformedBy e -> 400; + case StockMovementError.InvalidDateRange e -> 400; case StockMovementError.Unauthorized e -> 403; case StockMovementError.RepositoryFailure e -> 500; }; diff --git a/backend/src/main/resources/db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml b/backend/src/main/resources/db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml new file mode 100644 index 0000000..722dd9c --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 72fe12b..ad9fc29 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -34,5 +34,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java index 75e2f50..9a0f85c 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListStockMovementsTest.java @@ -62,7 +62,7 @@ class ListStockMovementsTest { void shouldReturnAll() { when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement))); - var result = useCase.execute(null, null, null, actor); + var result = useCase.execute(null, null, null, null, null, null, actor); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -74,7 +74,7 @@ class ListStockMovementsTest { void shouldReturnEmptyList() { when(repository.findAll()).thenReturn(Result.success(List.of())); - var result = useCase.execute(null, null, null, actor); + var result = useCase.execute(null, null, null, null, null, null, actor); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).isEmpty(); @@ -86,7 +86,7 @@ class ListStockMovementsTest { when(repository.findAll()).thenReturn( Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = useCase.execute(null, null, null, actor); + var result = useCase.execute(null, null, null, null, null, null, actor); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); @@ -103,7 +103,7 @@ class ListStockMovementsTest { when(repository.findAllByStockId(StockId.of("stock-1"))) .thenReturn(Result.success(List.of(sampleMovement))); - var result = useCase.execute("stock-1", null, null, actor); + var result = useCase.execute("stock-1", null, null, null, null, null, actor); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -114,7 +114,7 @@ class ListStockMovementsTest { @Test @DisplayName("should fail with InvalidStockId when format invalid") void shouldFailWhenStockIdInvalid() { - var result = useCase.execute(" ", null, null, actor); + var result = useCase.execute(" ", null, null, null, null, null, actor); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class); @@ -131,7 +131,7 @@ class ListStockMovementsTest { when(repository.findAllByArticleId(ArticleId.of("article-1"))) .thenReturn(Result.success(List.of(sampleMovement))); - var result = useCase.execute(null, "article-1", null, actor); + var result = useCase.execute(null, "article-1", null, null, null, null, actor); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -141,13 +141,55 @@ class ListStockMovementsTest { @Test @DisplayName("should fail with InvalidArticleId when format invalid") void shouldFailWhenArticleIdInvalid() { - var result = useCase.execute(null, " ", null, actor); + var result = useCase.execute(null, " ", null, null, null, null, actor); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class); } } + @Nested + @DisplayName("Filter by batchReference") + class BatchReferenceFilter { + + @Test + @DisplayName("should filter by batchReference") + void shouldFilterByBatchReference() { + when(repository.findAllByBatchId("CHARGE-001")) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAllByBatchId("CHARGE-001"); + } + + @Test + @DisplayName("should return empty list when no movements for batch") + void shouldReturnEmptyForUnknownBatch() { + when(repository.findAllByBatchId("UNKNOWN")) + .thenReturn(Result.success(List.of())); + + var result = useCase.execute(null, null, null, "UNKNOWN", null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).isEmpty(); + } + + @Test + @DisplayName("should fail when repository fails for batchReference") + void shouldFailWhenRepositoryFailsForBatch() { + when(repository.findAllByBatchId("CHARGE-001")) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); + } + } + @Nested @DisplayName("Filter by movementType") class MovementTypeFilter { @@ -158,7 +200,7 @@ class ListStockMovementsTest { when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT)) .thenReturn(Result.success(List.of(sampleMovement))); - var result = useCase.execute(null, null, "GOODS_RECEIPT", actor); + var result = useCase.execute(null, null, "GOODS_RECEIPT", null, null, null, actor); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -168,43 +210,182 @@ class ListStockMovementsTest { @Test @DisplayName("should fail with InvalidMovementType when type invalid") void shouldFailWhenMovementTypeInvalid() { - var result = useCase.execute(null, null, "INVALID_TYPE", actor); + var result = useCase.execute(null, null, "INVALID_TYPE", null, null, null, actor); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class); } } + @Nested + @DisplayName("Filter by date range") + class DateRangeFilter { + + private final Instant from = Instant.parse("2026-01-01T00:00:00Z"); + private final Instant to = Instant.parse("2026-12-31T23:59:59Z"); + + @Test + @DisplayName("should filter by from and to") + void shouldFilterByFromAndTo() { + when(repository.findAllByPerformedAtBetween(from, to)) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, null, null, from, to, actor); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(repository).findAllByPerformedAtBetween(from, to); + } + + @Test + @DisplayName("should filter with only from (open-ended)") + void shouldFilterByFromOnly() { + when(repository.findAllByPerformedAtBetween(eq(from), any())) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, null, null, from, null, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByPerformedAtBetween(eq(from), eq(Instant.MAX)); + } + + @Test + @DisplayName("should filter with only to (open-ended)") + void shouldFilterByToOnly() { + when(repository.findAllByPerformedAtBetween(any(), eq(to))) + .thenReturn(Result.success(List.of(sampleMovement))); + + var result = useCase.execute(null, null, null, null, null, to, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByPerformedAtBetween(eq(Instant.MIN), eq(to)); + } + + @Test + @DisplayName("should fail with InvalidDateRange when from is after to") + void shouldFailWhenFromAfterTo() { + var result = useCase.execute(null, null, null, null, to, from, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDateRange.class); + verify(repository, never()).findAllByPerformedAtBetween(any(), any()); + } + + @Test + @DisplayName("should succeed when from equals to (same instant)") + void shouldSucceedWhenFromEqualsTo() { + when(repository.findAllByPerformedAtBetween(from, from)) + .thenReturn(Result.success(List.of())); + + var result = useCase.execute(null, null, null, null, from, from, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByPerformedAtBetween(from, from); + } + + @Test + @DisplayName("should fail when repository fails for date range") + void shouldFailWhenRepositoryFailsForDateRange() { + when(repository.findAllByPerformedAtBetween(from, to)) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = useCase.execute(null, null, null, null, from, to, actor); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class); + } + } + @Nested @DisplayName("Filter priority") class FilterPriority { @Test - @DisplayName("stockId takes priority over articleId and movementType") + @DisplayName("stockId takes priority over articleId, batchReference and movementType") void stockIdTakesPriority() { when(repository.findAllByStockId(StockId.of("stock-1"))) .thenReturn(Result.success(List.of())); - var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", actor); + var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor); assertThat(result.isSuccess()).isTrue(); verify(repository).findAllByStockId(StockId.of("stock-1")); verify(repository, never()).findAllByArticleId(any()); + verify(repository, never()).findAllByBatchId(any()); verify(repository, never()).findAllByMovementType(any()); } @Test - @DisplayName("articleId takes priority over movementType") - void articleIdTakesPriorityOverMovementType() { + @DisplayName("articleId takes priority over batchReference and movementType") + void articleIdTakesPriorityOverBatchAndMovementType() { when(repository.findAllByArticleId(ArticleId.of("article-1"))) .thenReturn(Result.success(List.of())); - var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", actor); + var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor); assertThat(result.isSuccess()).isTrue(); verify(repository).findAllByArticleId(ArticleId.of("article-1")); + verify(repository, never()).findAllByBatchId(any()); verify(repository, never()).findAllByMovementType(any()); } + + @Test + @DisplayName("batchReference takes priority over movementType") + void batchReferenceTakesPriorityOverMovementType() { + when(repository.findAllByBatchId("CHARGE-001")) + .thenReturn(Result.success(List.of())); + + var result = useCase.execute(null, null, "GOODS_RECEIPT", "CHARGE-001", null, null, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByBatchId("CHARGE-001"); + verify(repository, never()).findAllByMovementType(any()); + } + + @Test + @DisplayName("movementType takes priority over from/to") + void movementTypeTakesPriorityOverDateRange() { + when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT)) + .thenReturn(Result.success(List.of())); + + Instant from = Instant.parse("2026-01-01T00:00:00Z"); + Instant to = Instant.parse("2026-12-31T23:59:59Z"); + var result = useCase.execute(null, null, "GOODS_RECEIPT", null, from, to, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByMovementType(MovementType.GOODS_RECEIPT); + verify(repository, never()).findAllByPerformedAtBetween(any(), any()); + } + + @Test + @DisplayName("stockId takes priority over from/to") + void stockIdTakesPriorityOverDateRange() { + when(repository.findAllByStockId(StockId.of("stock-1"))) + .thenReturn(Result.success(List.of())); + + Instant from = Instant.parse("2026-01-01T00:00:00Z"); + Instant to = Instant.parse("2026-12-31T23:59:59Z"); + var result = useCase.execute("stock-1", null, null, null, from, to, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByStockId(StockId.of("stock-1")); + verify(repository, never()).findAllByPerformedAtBetween(any(), any()); + } + + @Test + @DisplayName("batchReference takes priority over from/to") + void batchReferenceTakesPriorityOverDateRange() { + when(repository.findAllByBatchId("CHARGE-001")) + .thenReturn(Result.success(List.of())); + + Instant from = Instant.parse("2026-01-01T00:00:00Z"); + Instant to = Instant.parse("2026-12-31T23:59:59Z"); + var result = useCase.execute(null, null, null, "CHARGE-001", from, to, actor); + + assertThat(result.isSuccess()).isTrue(); + verify(repository).findAllByBatchId("CHARGE-001"); + verify(repository, never()).findAllByPerformedAtBetween(any(), any()); + } } @Nested @@ -216,7 +397,7 @@ class ListStockMovementsTest { void shouldFailWhenUnauthorized() { when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false); - var result = useCase.execute(null, null, null, actor); + var result = useCase.execute(null, null, null, null, null, null, actor); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class); diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java index 9b10f2d..50fe492 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockMovementControllerIntegrationTest.java @@ -477,6 +477,110 @@ class StockMovementControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$.length()").value(0)); } + + @Test + @DisplayName("Nach batchReference filtern → 200") + void filterByBatchReference_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("batchReference", "CHARGE-001") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Nach Zeitraum (from + to) filtern → 200") + void filterByDateRange_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("from", "2020-01-01T00:00:00Z") + .param("to", "2030-12-31T23:59:59Z") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Nur from (open-ended) → 200") + void filterByFromOnly_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("from", "2020-01-01T00:00:00Z") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("Nur to (open-ended) → 200") + void filterByToOnly_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("to", "2030-12-31T23:59:59Z") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("from nach to → 400") + void filterByInvalidDateRange_returns400() throws Exception { + mockMvc.perform(get("/api/inventory/stock-movements") + .param("from", "2030-01-01T00:00:00Z") + .param("to", "2020-01-01T00:00:00Z") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_DATE_RANGE")); + } + + @Test + @DisplayName("stockId hat Priorität über batchReference") + void stockIdPriorityOverBatchReference_returns200() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("stockId", stockId) + .param("batchReference", "CHARGE-001") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()); + } + + @Test + @DisplayName("Unbekannte batchReference → 200 mit []") + void filterByUnknownBatchReference_returns200Empty() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("batchReference", "UNKNOWN-CHARGE") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("Zeitraum in Zukunft → 200 mit []") + void filterByFutureDateRange_returns200Empty() throws Exception { + recordMovement("GOODS_RECEIPT"); + + mockMvc.perform(get("/api/inventory/stock-movements") + .param("from", "2099-01-01T00:00:00Z") + .param("to", "2099-12-31T23:59:59Z") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(0)); + } } // ==================== Einzelne Bewegung ==================== diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java index b550621..873a276 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java @@ -93,6 +93,27 @@ public final class InventoryScenario { ); } + public static ChainBuilder listStockMovementsByBatch() { + return exec(session -> { + int idx = ThreadLocalRandom.current().nextInt(50); + return session.set("filterBatchRef", "CHARGE-%05d".formatted(idx)); + }).exec( + http("Bestandsbewegungen nach Charge") + .get("/api/inventory/stock-movements?batchReference=#{filterBatchRef}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder listStockMovementsByDateRange() { + return exec( + http("Bestandsbewegungen nach Zeitraum") + .get("/api/inventory/stock-movements?from=2020-01-01T00:00:00Z&to=2030-12-31T23:59:59Z") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + public static ChainBuilder recordStockMovement() { return exec(session -> { var rnd = ThreadLocalRandom.current(); @@ -127,14 +148,16 @@ public final class InventoryScenario { .exec(AuthenticationScenario.login("admin", "admin123")) .repeat(15).on( randomSwitch().on( - percent(20.0).then(listStocks()), - percent(15.0).then(listStorageLocations()), - percent(15.0).then(getRandomStorageLocation()), - percent(15.0).then(listStocksByLocation()), - percent(10.0).then(listStocksBelowMinimum()), - percent(10.0).then(listStockMovements()), + percent(18.0).then(listStocks()), + percent(12.0).then(listStorageLocations()), + percent(12.0).then(getRandomStorageLocation()), + percent(12.0).then(listStocksByLocation()), + percent(8.0).then(listStocksBelowMinimum()), + percent(8.0).then(listStockMovements()), percent(5.0).then(listStockMovementsByStock()), - percent(10.0).then(recordStockMovement()) + percent(5.0).then(listStockMovementsByBatch()), + percent(5.0).then(listStockMovementsByDateRange()), + percent(15.0).then(recordStockMovement()) ).pause(1, 3) ); } diff --git a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java index ae8483e..050e025 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -103,6 +103,8 @@ public class FullWorkloadSimulation extends Simulation { details("Kategorien auflisten").responseTime().mean().lt(35), details("Bestandsbewegungen auflisten").responseTime().mean().lt(35), details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35), + details("Bestandsbewegungen nach Charge").responseTime().mean().lt(35), + details("Bestandsbewegungen nach Zeitraum").responseTime().mean().lt(35), // Listen mit viel Daten (50-300 Einträge): mean < 75ms details("Chargen auflisten").responseTime().mean().lt(75),