From 252f48d52bc9cb5f2ed22326b345a8a5dd312db7 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 26 Feb 2026 19:53:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20Inventur=20durchf=C3=BChren?= =?UTF-8?q?=20=E2=80=93=20Ist-Mengen=20erfassen=20(US-6.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert startCounting() und updateCountItem() auf dem InventoryCount- Aggregate, zwei neue Use Cases (StartInventoryCount, RecordCountItem) mit zugehörigen Controller-Endpoints (PATCH /{id}/start, PATCH /{id}/items/{itemId}). Inkl. Domain-, Application-, Integrations- und Gatling-Lasttests. --- .../inventory/RecordCountItem.java | 102 ++++++ .../inventory/StartInventoryCount.java | 69 ++++ .../command/RecordCountItemCommand.java | 8 + .../effigenix/domain/inventory/CountItem.java | 4 + .../domain/inventory/InventoryCount.java | 52 ++- .../domain/inventory/InventoryCountError.java | 5 + .../config/InventoryUseCaseConfiguration.java | 12 + .../controller/InventoryCountController.java | 45 ++- .../web/dto/RecordCountItemRequest.java | 8 + .../InventoryErrorHttpStatusMapper.java | 1 + .../inventory/RecordCountItemTest.java | 309 ++++++++++++++++++ .../inventory/StartInventoryCountTest.java | 193 +++++++++++ .../domain/inventory/InventoryCountTest.java | 242 ++++++++++++++ ...ventoryCountControllerIntegrationTest.java | 248 +++++++++++++- .../infrastructure/LoadTestDataSeeder.java | 61 +++- .../loadtest/scenario/InventoryScenario.java | 104 +++++- .../simulation/FullWorkloadSimulation.java | 5 + 17 files changed, 1451 insertions(+), 17 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/RecordCountItem.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/StartInventoryCount.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/RecordCountItemCommand.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordCountItemRequest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/RecordCountItem.java b/backend/src/main/java/de/effigenix/application/inventory/RecordCountItem.java new file mode 100644 index 0000000..28c4f75 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/RecordCountItem.java @@ -0,0 +1,102 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RecordCountItemCommand; +import de.effigenix.domain.inventory.CountItemId; +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.InventoryCount; +import de.effigenix.domain.inventory.InventoryCountError; +import de.effigenix.domain.inventory.InventoryCountId; +import de.effigenix.domain.inventory.InventoryCountRepository; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +import java.math.BigDecimal; + +public class RecordCountItem { + + private final InventoryCountRepository inventoryCountRepository; + private final UnitOfWork unitOfWork; + private final AuthorizationPort authPort; + + public RecordCountItem(InventoryCountRepository inventoryCountRepository, + UnitOfWork unitOfWork, + AuthorizationPort authPort) { + this.inventoryCountRepository = inventoryCountRepository; + this.unitOfWork = unitOfWork; + this.authPort = authPort; + } + + public Result execute(RecordCountItemCommand cmd, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to record count items")); + } + + if (cmd.inventoryCountId() == null || cmd.inventoryCountId().isBlank()) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank")); + } + + InventoryCountId id; + try { + id = InventoryCountId.of(cmd.inventoryCountId()); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage())); + } + + CountItemId itemId; + try { + itemId = CountItemId.of(cmd.countItemId()); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidCountItemId(e.getMessage())); + } + + // Parse quantity + BigDecimal amount; + try { + amount = new BigDecimal(cmd.actualQuantityAmount()); + } catch (NumberFormatException | NullPointerException e) { + return Result.failure(new InventoryCountError.InvalidQuantity( + "Invalid quantity amount: " + cmd.actualQuantityAmount())); + } + + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(cmd.actualQuantityUnit()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new InventoryCountError.InvalidQuantity( + "Invalid unit: " + cmd.actualQuantityUnit())); + } + + Quantity actualQuantity = Quantity.reconstitute(amount, uom); + + // Load aggregate + InventoryCount count; + switch (inventoryCountRepository.findById(id)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var optCount) -> { + if (optCount.isEmpty()) { + return Result.failure(new InventoryCountError.InventoryCountNotFound(cmd.inventoryCountId())); + } + count = optCount.get(); + } + } + + switch (count.updateCountItem(itemId, actualQuantity)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + return unitOfWork.executeAtomically(() -> { + switch (inventoryCountRepository.save(count)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + return Result.success(count); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/StartInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/StartInventoryCount.java new file mode 100644 index 0000000..7b0b6f0 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/StartInventoryCount.java @@ -0,0 +1,69 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.InventoryAction; +import de.effigenix.domain.inventory.InventoryCount; +import de.effigenix.domain.inventory.InventoryCountError; +import de.effigenix.domain.inventory.InventoryCountId; +import de.effigenix.domain.inventory.InventoryCountRepository; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +public class StartInventoryCount { + + private final InventoryCountRepository inventoryCountRepository; + private final UnitOfWork unitOfWork; + private final AuthorizationPort authPort; + + public StartInventoryCount(InventoryCountRepository inventoryCountRepository, + UnitOfWork unitOfWork, + AuthorizationPort authPort) { + this.inventoryCountRepository = inventoryCountRepository; + this.unitOfWork = unitOfWork; + this.authPort = authPort; + } + + public Result execute(String inventoryCountId, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to start inventory count")); + } + + if (inventoryCountId == null || inventoryCountId.isBlank()) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank")); + } + + InventoryCountId id; + try { + id = InventoryCountId.of(inventoryCountId); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage())); + } + + InventoryCount count; + switch (inventoryCountRepository.findById(id)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var optCount) -> { + if (optCount.isEmpty()) { + return Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId)); + } + count = optCount.get(); + } + } + + switch (count.startCounting()) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + return unitOfWork.executeAtomically(() -> { + switch (inventoryCountRepository.save(count)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + return Result.success(count); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/RecordCountItemCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/RecordCountItemCommand.java new file mode 100644 index 0000000..4a314fc --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/RecordCountItemCommand.java @@ -0,0 +1,8 @@ +package de.effigenix.application.inventory.command; + +public record RecordCountItemCommand( + String inventoryCountId, + String countItemId, + String actualQuantityAmount, + String actualQuantityUnit +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java index 286885c..b4d2e07 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/CountItem.java @@ -89,6 +89,10 @@ public class CountItem { return actualQuantity.amount().subtract(expectedQuantity.amount()); } + void recordActualQuantity(Quantity actualQuantity) { + this.actualQuantity = actualQuantity; + } + public boolean isCounted() { return actualQuantity != null; } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java index 05a4d5e..31bd003 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java @@ -1,8 +1,10 @@ package de.effigenix.domain.inventory; import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Result; +import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeParseException; @@ -21,6 +23,8 @@ import java.util.Objects; * - Max. one active count (OPEN/COUNTING) per StorageLocation (Application Layer) * - ArticleId unique within countItems * - addCountItem only in status OPEN + * - startCounting only in status OPEN, requires non-empty countItems + * - updateCountItem only in status COUNTING */ public class InventoryCount { @@ -28,7 +32,7 @@ public class InventoryCount { private final StorageLocationId storageLocationId; private final LocalDate countDate; private final String initiatedBy; - private final InventoryCountStatus status; + private InventoryCountStatus status; private final Instant createdAt; private final List countItems; @@ -110,7 +114,7 @@ public class InventoryCount { public Result addCountItem(CountItemDraft draft) { if (status != InventoryCountStatus.OPEN) { return Result.failure(new InventoryCountError.InvalidStatusTransition( - status.name(), "addCountItem requires OPEN")); + status.name(), InventoryCountStatus.OPEN.name())); } CountItem item; @@ -127,6 +131,50 @@ public class InventoryCount { return Result.success(item); } + // ==================== Status Transitions ==================== + + public Result startCounting() { + if (status != InventoryCountStatus.OPEN) { + return Result.failure(new InventoryCountError.InvalidStatusTransition( + status.name(), InventoryCountStatus.COUNTING.name())); + } + if (countItems.isEmpty()) { + return Result.failure(new InventoryCountError.NoCountItems()); + } + this.status = InventoryCountStatus.COUNTING; + return Result.success(null); + } + + public Result updateCountItem(CountItemId itemId, Quantity actualQuantity) { + if (status != InventoryCountStatus.COUNTING) { + return Result.failure(new InventoryCountError.InvalidStatusTransition( + status.name(), InventoryCountStatus.COUNTING.name())); + } + + var item = countItems.stream() + .filter(ci -> ci.id().equals(itemId)) + .findFirst() + .orElse(null); + + if (item == null) { + return Result.failure(new InventoryCountError.CountItemNotFound(itemId.value())); + } + + if (actualQuantity.uom() != item.expectedQuantity().uom()) { + return Result.failure(new InventoryCountError.InvalidQuantity( + "Unit of measure mismatch: expected " + item.expectedQuantity().uom() + + " but got " + actualQuantity.uom())); + } + + if (actualQuantity.amount().compareTo(BigDecimal.ZERO) < 0) { + return Result.failure(new InventoryCountError.InvalidQuantity( + "Actual quantity must be >= 0")); + } + + item.recordActualQuantity(actualQuantity); + return Result.success(null); + } + // ==================== Queries ==================== public boolean isActive() { diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java index 9df18af..0d661d3 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java @@ -55,6 +55,11 @@ public sealed interface InventoryCountError { @Override public String message() { return "Not all count items have been counted"; } } + record InvalidCountItemId(String reason) implements InventoryCountError { + @Override public String code() { return "INVALID_COUNT_ITEM_ID"; } + @Override public String message() { return "Invalid count item ID: " + reason; } + } + record CountItemNotFound(String id) implements InventoryCountError { @Override public String code() { return "COUNT_ITEM_NOT_FOUND"; } @Override public String message() { return "Count item not found: " + id; } diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index 12ba8a0..25a1163 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -3,6 +3,8 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.ListInventoryCounts; +import de.effigenix.application.inventory.RecordCountItem; +import de.effigenix.application.inventory.StartInventoryCount; import de.effigenix.application.inventory.GetStockMovement; import de.effigenix.application.inventory.ListStockMovements; import de.effigenix.application.inventory.ConfirmReservation; @@ -172,4 +174,14 @@ public class InventoryUseCaseConfiguration { public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) { return new ListInventoryCounts(inventoryCountRepository, authorizationPort); } + + @Bean + public StartInventoryCount startInventoryCount(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { + return new StartInventoryCount(inventoryCountRepository, unitOfWork, authorizationPort); + } + + @Bean + public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { + return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java index 1403dbf..36a7374 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java @@ -3,10 +3,14 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.ListInventoryCounts; +import de.effigenix.application.inventory.RecordCountItem; +import de.effigenix.application.inventory.StartInventoryCount; import de.effigenix.application.inventory.command.CreateInventoryCountCommand; +import de.effigenix.application.inventory.command.RecordCountItemCommand; import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; +import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest; import de.effigenix.shared.security.ActorId; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -28,13 +32,19 @@ public class InventoryCountController { private final CreateInventoryCount createInventoryCount; private final GetInventoryCount getInventoryCount; private final ListInventoryCounts listInventoryCounts; + private final StartInventoryCount startInventoryCount; + private final RecordCountItem recordCountItem; public InventoryCountController(CreateInventoryCount createInventoryCount, GetInventoryCount getInventoryCount, - ListInventoryCounts listInventoryCounts) { + ListInventoryCounts listInventoryCounts, + StartInventoryCount startInventoryCount, + RecordCountItem recordCountItem) { this.createInventoryCount = createInventoryCount; this.getInventoryCount = getInventoryCount; this.listInventoryCounts = listInventoryCounts; + this.startInventoryCount = startInventoryCount; + this.recordCountItem = recordCountItem; } @PostMapping @@ -92,6 +102,39 @@ public class InventoryCountController { return ResponseEntity.ok(responses); } + @PatchMapping("/{id}/start") + @PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')") + public ResponseEntity startInventoryCount( + @PathVariable String id, + Authentication authentication + ) { + var result = startInventoryCount.execute(id, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); + } + + @PatchMapping("/{id}/items/{itemId}") + @PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')") + public ResponseEntity recordCountItem( + @PathVariable String id, + @PathVariable String itemId, + @Valid @RequestBody RecordCountItemRequest request, + Authentication authentication + ) { + var cmd = new RecordCountItemCommand(id, itemId, request.actualQuantityAmount(), request.actualQuantityUnit()); + var result = recordCountItem.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); + } + // ==================== Exception Wrapper ==================== public static class InventoryCountDomainErrorException extends RuntimeException { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordCountItemRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordCountItemRequest.java new file mode 100644 index 0000000..457771b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RecordCountItemRequest.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RecordCountItemRequest( + @NotBlank String actualQuantityAmount, + @NotBlank String actualQuantityUnit +) {} 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 d5e59e2..1564a32 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 @@ -56,6 +56,7 @@ public final class InventoryErrorHttpStatusMapper { public static int toHttpStatus(InventoryCountError error) { return switch (error) { case InventoryCountError.InventoryCountNotFound e -> 404; + case InventoryCountError.InvalidCountItemId e -> 400; case InventoryCountError.CountItemNotFound e -> 404; case InventoryCountError.ActiveCountExists e -> 409; case InventoryCountError.DuplicateArticle e -> 409; diff --git a/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java b/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java new file mode 100644 index 0000000..26ffb83 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java @@ -0,0 +1,309 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RecordCountItemCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.persistence.UnitOfWork; +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.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecordCountItem Use Case") +class RecordCountItemTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private UnitOfWork unitOfWork; + @Mock private AuthorizationPort authPort; + + private RecordCountItem recordCountItem; + private final ActorId actorId = ActorId.of("user-1"); + + @BeforeEach + void setUp() { + recordCountItem = new RecordCountItem(inventoryCountRepository, unitOfWork, authPort); + lenient().when(authPort.can(any(ActorId.class), any())).thenReturn(true); + } + + private void stubUnitOfWork() { + when(unitOfWork.executeAtomically(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + var supplier = (java.util.function.Supplier>) invocation.getArgument(0); + return supplier.get(); + }); + } + + private InventoryCount createCountingCount() { + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null), + CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"), + Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER), null) + ) + ); + } + + @Test + @DisplayName("should record actual quantity on count item") + void shouldRecordActualQuantity() { + stubUnitOfWork(); + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.5", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isSuccess()).isTrue(); + var item = result.unsafeGetValue().countItems().stream() + .filter(ci -> ci.id().equals(CountItemId.of("item-1"))) + .findFirst().orElseThrow(); + assertThat(item.actualQuantity().amount()).isEqualByComparingTo(new BigDecimal("8.5")); + assertThat(item.isCounted()).isTrue(); + } + + @Test + @DisplayName("should fail when not authorized") + void shouldFailWhenNotAuthorized() { + when(authPort.can(any(ActorId.class), any())).thenReturn(false); + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class); + verify(inventoryCountRepository, never()).findById(any()); + } + + @Test + @DisplayName("should fail when inventory count not found") + void shouldFailWhenNotFound() { + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } + + @Test + @DisplayName("should fail when count item not found") + void shouldFailWhenCountItemNotFound() { + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "nonexistent-but-valid", "8.0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountItemNotFound.class); + } + + @Test + @DisplayName("should fail when countItemId is blank") + void shouldFailWhenCountItemIdBlank() { + var cmd = new RecordCountItemCommand("count-1", " ", "8.0", "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountItemId.class); + } + + @Test + @DisplayName("should fail when countItemId is null") + void shouldFailWhenCountItemIdNull() { + var cmd = new RecordCountItemCommand("count-1", null, "8.0", "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidCountItemId.class); + } + + @Test + @DisplayName("should fail when quantity amount is invalid") + void shouldFailWhenQuantityAmountInvalid() { + var cmd = new RecordCountItemCommand("count-1", "item-1", "not-a-number", "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity unit is invalid") + void shouldFailWhenUnitInvalid() { + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "INVALID_UNIT"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when UOM does not match expected") + void shouldFailWhenUomMismatch() { + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "LITER"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when inventoryCountId is blank") + void shouldFailWhenIdBlank() { + var cmd = new RecordCountItemCommand("", "item-1", "8.0", "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); + } + + @Test + @DisplayName("should fail when quantity amount is null") + void shouldFailWhenQuantityAmountNull() { + var cmd = new RecordCountItemCommand("count-1", "item-1", null, "KILOGRAM"); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when quantity unit is null") + void shouldFailWhenUnitNull() { + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", null); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when status is OPEN") + void shouldFailWhenStatusIsOpen() { + var openCount = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.OPEN, + Instant.now(), List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) + ) + ); + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(openCount))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when repository findById fails") + void shouldFailWhenRepositoryFails() { + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when save fails") + void shouldFailWhenSaveFails() { + stubUnitOfWork(); + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "item-1", "8.0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed"))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should allow zero actual quantity") + void shouldAllowZeroActualQuantity() { + stubUnitOfWork(); + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "item-1", "0", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isSuccess()).isTrue(); + } + + @Test + @DisplayName("should fail when negative actual quantity") + void shouldFailWhenNegativeQuantity() { + var count = createCountingCount(); + var cmd = new RecordCountItemCommand("count-1", "item-1", "-5", "KILOGRAM"); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + + var result = recordCountItem.execute(cmd, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java new file mode 100644 index 0000000..a75a927 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java @@ -0,0 +1,193 @@ +package de.effigenix.application.inventory; + +import de.effigenix.domain.inventory.*; +import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.RepositoryError; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import de.effigenix.shared.persistence.UnitOfWork; +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.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StartInventoryCount Use Case") +class StartInventoryCountTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private UnitOfWork unitOfWork; + @Mock private AuthorizationPort authPort; + + private StartInventoryCount startInventoryCount; + private final ActorId actorId = ActorId.of("user-1"); + + @BeforeEach + void setUp() { + startInventoryCount = new StartInventoryCount(inventoryCountRepository, unitOfWork, authPort); + lenient().when(authPort.can(any(ActorId.class), any())).thenReturn(true); + } + + private void stubUnitOfWork() { + when(unitOfWork.executeAtomically(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + var supplier = (java.util.function.Supplier>) invocation.getArgument(0); + return supplier.get(); + }); + } + + private InventoryCount createOpenCountWithItems() { + var items = List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) + ); + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.OPEN, + Instant.now(), items + ); + } + + @Test + @DisplayName("should start counting for OPEN count with items") + void shouldStartCounting() { + stubUnitOfWork(); + var count = createOpenCountWithItems(); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.success(null)); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.COUNTING); + verify(inventoryCountRepository).save(any(InventoryCount.class)); + } + + @Test + @DisplayName("should fail when not authorized") + void shouldFailWhenNotAuthorized() { + when(authPort.can(any(ActorId.class), any())).thenReturn(false); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class); + verify(inventoryCountRepository, never()).findById(any()); + } + + @Test + @DisplayName("should fail when inventory count not found") + void shouldFailWhenNotFound() { + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + } + + @Test + @DisplayName("should fail when count has no items") + void shouldFailWhenNoItems() { + var emptyCount = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.OPEN, + Instant.now(), List.of() + ); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(emptyCount))); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.NoCountItems.class); + } + + @Test + @DisplayName("should fail when count is already COUNTING") + void shouldFailWhenAlreadyCounting() { + var countingCount = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) + ) + ); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countingCount))); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when inventoryCountId is blank") + void shouldFailWhenIdBlank() { + var result = startInventoryCount.execute("", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); + } + + @Test + @DisplayName("should fail when inventoryCountId is null") + void shouldFailWhenIdNull() { + var result = startInventoryCount.execute(null, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class); + } + + @Test + @DisplayName("should fail when repository findById fails") + void shouldFailWhenRepositoryFails() { + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when save fails") + void shouldFailWhenSaveFails() { + stubUnitOfWork(); + var count = createOpenCountWithItems(); + + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(count))); + when(inventoryCountRepository.save(any(InventoryCount.class))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed"))); + + var result = startInventoryCount.execute("count-1", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java index 95dd32d..5dfd949 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.*; @@ -386,6 +387,231 @@ class InventoryCountTest { } } + // ==================== startCounting ==================== + + @Nested + @DisplayName("startCounting()") + class StartCounting { + + @Test + @DisplayName("should transition from OPEN to COUNTING when countItems exist") + void shouldTransitionToCountingWhenItemsExist() { + var count = createOpenCount(); + count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + + var result = count.startCounting(); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.status()).isEqualTo(InventoryCountStatus.COUNTING); + } + + @Test + @DisplayName("should fail when status is COUNTING") + void shouldFailWhenStatusIsCounting() { + var count = createCountingCount(); + + var result = count.startCounting(); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when status is COMPLETED") + void shouldFailWhenStatusIsCompleted() { + var count = reconstitute(InventoryCountStatus.COMPLETED, createCountItems()); + + var result = count.startCounting(); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when status is CANCELLED") + void shouldFailWhenStatusIsCancelled() { + var count = reconstitute(InventoryCountStatus.CANCELLED, createCountItems()); + + var result = count.startCounting(); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when countItems is empty") + void shouldFailWhenNoCountItems() { + var count = createOpenCount(); + + var result = count.startCounting(); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.NoCountItems.class); + } + + private InventoryCount reconstitute(InventoryCountStatus status, List items) { + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", status, + Instant.now(), items + ); + } + } + + // ==================== updateCountItem ==================== + + @Nested + @DisplayName("updateCountItem()") + class UpdateCountItem { + + @Test + @DisplayName("should record actual quantity on count item") + void shouldRecordActualQuantity() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(itemId, actualQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.countItems().getFirst().actualQuantity()).isEqualTo(actualQty); + assertThat(count.countItems().getFirst().isCounted()).isTrue(); + } + + @Test + @DisplayName("should allow zero actual quantity") + void shouldAllowZeroActualQuantity() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(BigDecimal.ZERO, UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(itemId, actualQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.countItems().getFirst().actualQuantity().amount()) + .isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("should compute deviation after recording") + void shouldComputeDeviationAfterRecording() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(new BigDecimal("7.0"), UnitOfMeasure.KILOGRAM); + + count.updateCountItem(itemId, actualQty); + + assertThat(count.countItems().getFirst().deviation()) + .isEqualByComparingTo(new BigDecimal("-3.0")); + } + + @Test + @DisplayName("should fail when status is OPEN") + void shouldFailWhenStatusIsOpen() { + var count = createOpenCount(); + count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM")); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(itemId, actualQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when status is COMPLETED") + void shouldFailWhenStatusIsCompleted() { + var items = createCountItems(); + var count = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED, + Instant.now(), items + ); + var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(items.getFirst().id(), actualQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when countItem not found") + void shouldFailWhenCountItemNotFound() { + var count = createCountingCount(); + var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(CountItemId.of("nonexistent"), actualQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CountItemNotFound.class); + } + + @Test + @DisplayName("should fail when UOM does not match expected quantity") + void shouldFailWhenUomMismatch() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.LITER); + + var result = count.updateCountItem(itemId, actualQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should fail when actual quantity is negative") + void shouldFailWhenNegativeQuantity() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + var actualQty = Quantity.reconstitute(new BigDecimal("-1.0"), UnitOfMeasure.KILOGRAM); + + var result = count.updateCountItem(itemId, actualQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidQuantity.class); + } + + @Test + @DisplayName("should allow overwriting previously recorded quantity") + void shouldAllowOverwritingRecordedQuantity() { + var count = createCountingCount(); + var itemId = count.countItems().getFirst().id(); + + count.updateCountItem(itemId, Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)); + var result = count.updateCountItem(itemId, Quantity.reconstitute(new BigDecimal("12.0"), UnitOfMeasure.KILOGRAM)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.countItems().getFirst().actualQuantity().amount()) + .isEqualByComparingTo(new BigDecimal("12.0")); + } + + @Test + @DisplayName("should update specific item in multi-item count") + void shouldUpdateSpecificItemInMultiItemCount() { + var items = List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null), + CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"), + Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER), null) + ); + var count = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), items + ); + var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER); + + var result = count.updateCountItem(CountItemId.of("item-2"), actualQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.countItems().get(1).actualQuantity()).isEqualTo(actualQty); + assertThat(count.countItems().getFirst().actualQuantity()).isNull(); + } + } + // ==================== CountItem deviation ==================== @Nested @@ -599,4 +825,20 @@ class InventoryCountTest { new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1") ).unsafeGetValue(); } + + private InventoryCount createCountingCount() { + var items = createCountItems(); + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, + Instant.now(), items + ); + } + + private List createCountItems() { + return new ArrayList<>(List.of( + CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), + Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) + )); + } } diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java index aaa5d5e..94cee88 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java @@ -12,7 +12,7 @@ import java.time.LocalDate; import java.util.Set; import java.util.UUID; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -21,6 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * * Abgedeckte Testfälle: * - US-6.1 – Inventur anlegen und Zählpositionen befüllen + * - US-6.2 – Inventur durchführen (Ist-Mengen erfassen) */ @DisplayName("InventoryCount Controller Integration Tests") class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { @@ -295,8 +296,253 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.length()").value(0)); } + // ==================== US-6.2: Inventur starten ==================== + + @Test + @DisplayName("Inventur starten → 200 mit Status COUNTING") + void startInventoryCount_returns200() throws Exception { + String countId = createInventoryCountWithStock(); + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(countId)) + .andExpect(jsonPath("$.status").value("COUNTING")); + } + + @Test + @DisplayName("Inventur ohne Items starten → 400 NoCountItems") + void startInventoryCount_noItems_returns400() throws Exception { + // Inventur ohne Stock (= ohne Items) anlegen + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + var createResult = mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + String countId = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText(); + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("NO_COUNT_ITEMS")); + } + + @Test + @DisplayName("Bereits gestartete Inventur nochmal starten → 400") + void startInventoryCount_alreadyCounting_returns400() throws Exception { + String countId = createInventoryCountWithStock(); + + // Erster Start + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + // Zweiter Start + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Inventur starten mit unbekannter ID → 404") + void startInventoryCount_notFound_returns404() throws Exception { + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("INVENTORY_COUNT_NOT_FOUND")); + } + + @Test + @DisplayName("Inventur starten ohne Token → 401") + void startInventoryCount_noToken_returns401() throws Exception { + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/start")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Inventur starten ohne INVENTORY_COUNT_WRITE → 403") + void startInventoryCount_noPermission_returns403() throws Exception { + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/start") + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + // ==================== US-6.2: Ist-Mengen erfassen ==================== + + @Test + @DisplayName("Ist-Menge erfassen → 200 mit actualQuantity und Deviation") + void recordCountItem_returns200() throws Exception { + String countId = createInventoryCountWithStock(); + + // Inventur starten + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + // Item-ID holen + String itemId = getFirstCountItemId(countId); + + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("COUNTING")) + .andExpect(jsonPath("$.countItems[0].actualQuantityAmount").isNumber()) + .andExpect(jsonPath("$.countItems[0].deviation").isNumber()); + } + + @Test + @DisplayName("Ist-Menge erfassen in OPEN Status → 409") + void recordCountItem_openStatus_returns409() throws Exception { + String countId = createInventoryCountWithStock(); + + // NICHT starten → bleibt OPEN + String itemId = getFirstCountItemId(countId); + + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Ist-Menge mit falscher UOM → 400") + void recordCountItem_wrongUom_returns400() throws Exception { + String countId = createInventoryCountWithStock(); + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + String itemId = getFirstCountItemId(countId); + + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "LITER"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_QUANTITY")); + } + + @Test + @DisplayName("Ist-Menge für unbekanntes Item → 400") + void recordCountItem_itemNotFound_returns400() throws Exception { + String countId = createInventoryCountWithStock(); + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("COUNT_ITEM_NOT_FOUND")); + } + + @Test + @DisplayName("Ist-Menge erfassen ohne Token → 401") + void recordCountItem_noToken_returns401() throws Exception { + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/items/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Ist-Menge erfassen ohne Permission → 403") + void recordCountItem_noPermission_returns403() throws Exception { + String body = """ + {"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/items/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ist-Menge ohne Body-Felder → 400 (Validation)") + void recordCountItem_missingFields_returns400() throws Exception { + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/items/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Ist-Menge mit Null-Wert für actualQuantityAmount → 400") + void recordCountItem_nullAmount_returns400() throws Exception { + String body = """ + {"actualQuantityAmount": null, "actualQuantityUnit": "KILOGRAM"} + """; + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/items/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + // ==================== Helpers ==================== + private String createInventoryCountWithStock() throws Exception { + String articleId = createArticleId(); + createStockWithBatch(articleId, storageLocationId); + + var request = new CreateInventoryCountRequest(storageLocationId, LocalDate.now().toString()); + + var result = mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + + private String getFirstCountItemId(String countId) throws Exception { + var getResult = mockMvc.perform(get("/api/inventory/inventory-counts/" + countId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andReturn(); + + return objectMapper.readTree(getResult.getResponse().getContentAsString()) + .get("countItems").get(0).get("id").asText(); + } + private String createStorageLocation() throws Exception { String json = """ {"name": "Testlager-%s", "storageType": "DRY_STORAGE"} diff --git a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java index 741686c..0fb2693 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/infrastructure/LoadTestDataSeeder.java @@ -44,6 +44,7 @@ public final class LoadTestDataSeeder { private final List stockIds = new ArrayList<>(); private final List stockBatchIds = new ArrayList<>(); private final List stockMovementIds = new ArrayList<>(); + private final List inventoryCountIds = new ArrayList<>(); // Statische Felder für Zugriff aus Szenarien private static List seededCategoryIds; @@ -57,6 +58,7 @@ public final class LoadTestDataSeeder { private static List seededStockIds; private static List seededStockBatchIds; private static List seededStockMovementIds; + private static List seededInventoryCountIds; public LoadTestDataSeeder(ConfigurableApplicationContext appContext) { int port = appContext.getEnvironment() @@ -80,6 +82,7 @@ public final class LoadTestDataSeeder { seedProductionOrders(); seedStocksAndBatches(); seedStockMovements(); + seedInventoryCounts(); // Statische Referenzen setzen seededCategoryIds = List.copyOf(categoryIds); @@ -93,16 +96,17 @@ public final class LoadTestDataSeeder { seededStockIds = List.copyOf(stockIds); seededStockBatchIds = List.copyOf(stockBatchIds); seededStockMovementIds = List.copyOf(stockMovementIds); + seededInventoryCountIds = List.copyOf(inventoryCountIds); long duration = System.currentTimeMillis() - start; System.out.printf( "Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, " + "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge, " - + "%d Bestände, %d Bestandsbewegungen%n", + + "%d Bestände, %d Bestandsbewegungen, %d Inventuren%n", duration, categoryIds.size(), articleIds.size(), supplierIds.size(), customerIds.size(), storageLocationIds.size(), recipeIds.size(), batchIds.size(), productionOrderIds.size(), - stockIds.size(), stockMovementIds.size()); + stockIds.size(), stockMovementIds.size(), inventoryCountIds.size()); } catch (Exception e) { throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", e); } @@ -649,6 +653,33 @@ public final class LoadTestDataSeeder { } } + // ---- Inventuren (3 pro Lagerort mit Stocks, davon 2 gestartet) ---- + + private void seedInventoryCounts() throws Exception { + if (storageLocationIds.isEmpty() || stockIds.isEmpty()) return; + + // Nur Lagerorte nutzen, die auch Stocks haben (maximal 3 Lagerorte) + int countLimit = Math.min(3, storageLocationIds.size()); + for (int i = 0; i < countLimit; i++) { + String locationId = storageLocationIds.get(i); + + String body = """ + {"storageLocationId":"%s","countDate":"%s"}""" + .formatted(locationId, LocalDate.now()); + + try { + var json = mapper.readTree(post("/api/inventory/inventory-counts", body, adminToken)); + String countId = json.get("id").asText(); + inventoryCountIds.add(countId); + + // 2 von 3 Inventuren starten → Status COUNTING + if (i < 2) { + tryPatch("/api/inventory/inventory-counts/" + countId + "/start", adminToken); + } + } catch (Exception ignored) {} + } + } + // ---- HTTP Helper ---- private String post(String path, String jsonBody, String token) throws Exception { @@ -676,6 +707,31 @@ public final class LoadTestDataSeeder { } } + /** Patch ohne Exception bei Fehler – für optionale Status-Übergänge. */ + private void tryPatch(String path, String token) { + try { + patch(path, token); + } catch (Exception ignored) { + // Status-Übergang fehlgeschlagen, z.B. wegen Vorbedingungen + } + } + + private String patch(String path, String token) throws Exception { + var builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .method("PATCH", HttpRequest.BodyPublishers.ofString("{}")); + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new RuntimeException("HTTP %d auf %s: %s".formatted( + response.statusCode(), path, response.body())); + } + return response.body(); + } + // ---- Statische Getter für Szenarien ---- public static List categoryIds() { return seededCategoryIds; } @@ -689,4 +745,5 @@ public final class LoadTestDataSeeder { public static List stockIds() { return seededStockIds; } public static List stockBatchIds() { return seededStockBatchIds; } public static List stockMovementIds() { return seededStockMovementIds; } + public static List inventoryCountIds() { return seededInventoryCountIds; } } 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 42ecb77..40b4096 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java @@ -153,6 +153,84 @@ public final class InventoryScenario { ); } + public static ChainBuilder listInventoryCounts() { + return exec( + http("Inventuren auflisten") + .get("/api/inventory/inventory-counts") + .header("Authorization", "Bearer #{accessToken}") + .check(status().is(200)) + ); + } + + public static ChainBuilder getRandomInventoryCount() { + return exec(session -> { + var ids = LoadTestDataSeeder.inventoryCountIds(); + if (ids == null || ids.isEmpty()) return session; + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("inventoryCountId", id); + }).doIf(session -> session.contains("inventoryCountId")).then( + exec( + http("Inventur laden") + .get("/api/inventory/inventory-counts/#{inventoryCountId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 404)) + ) + ); + } + + public static ChainBuilder createAndStartInventoryCount() { + return exec(session -> { + var ids = LoadTestDataSeeder.storageLocationIds(); + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("icStorageLocationId", id); + }).exec( + http("Inventur anlegen") + .post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer #{accessToken}") + .body(StringBody(""" + {"storageLocationId":"#{icStorageLocationId}","countDate":"%s"}""" + .formatted(java.time.LocalDate.now()))) + .check(status().in(201, 409)) + .check(jsonPath("$.id").optional().saveAs("newInventoryCountId")) + ).doIf(session -> session.contains("newInventoryCountId")).then( + exec( + http("Inventur starten") + .patch("/api/inventory/inventory-counts/#{newInventoryCountId}/start") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 400)) + ).exec(session -> session.remove("newInventoryCountId")) + ); + } + + public static ChainBuilder recordCountItem() { + return exec(session -> { + var ids = LoadTestDataSeeder.inventoryCountIds(); + if (ids == null || ids.isEmpty()) return session; + String id = ids.get(ThreadLocalRandom.current().nextInt(ids.size())); + return session.set("recordCountId", id); + }).doIf(session -> session.contains("recordCountId")).then( + exec( + http("Inventur laden für Zählung") + .get("/api/inventory/inventory-counts/#{recordCountId}") + .header("Authorization", "Bearer #{accessToken}") + .check(status().in(200, 404)) + .check(jsonPath("$.countItems[0].id").optional().saveAs("recordItemId")) + ).doIf(session -> session.contains("recordItemId")).then( + exec(session -> { + int qty = ThreadLocalRandom.current().nextInt(0, 50); + return session.set("recordQty", "%d.0".formatted(qty)); + }).exec( + http("Ist-Menge erfassen") + .patch("/api/inventory/inventory-counts/#{recordCountId}/items/#{recordItemId}") + .header("Authorization", "Bearer #{accessToken}") + .body(StringBody(""" + {"actualQuantityAmount":"#{recordQty}","actualQuantityUnit":"KILOGRAM"}""")) + .check(status().in(200, 400)) + ).exec(session -> session.remove("recordItemId")) + ).exec(session -> session.remove("recordCountId")) + ); + } + public static ChainBuilder recordStockMovement() { return exec(session -> { var rnd = ThreadLocalRandom.current(); @@ -189,17 +267,21 @@ public final class InventoryScenario { .exec(AuthenticationScenario.login("admin", "admin123")) .repeat(15).on( randomSwitch().on( - 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(5.0).then(listStockMovementsByBatch()), - percent(5.0).then(listStockMovementsByDateRange()), - percent(8.0).then(reserveAndConfirmStock()), - percent(7.0).then(recordStockMovement()) + percent(15.0).then(listStocks()), + percent(10.0).then(listStorageLocations()), + percent(10.0).then(getRandomStorageLocation()), + percent(10.0).then(listStocksByLocation()), + percent(6.0).then(listStocksBelowMinimum()), + percent(6.0).then(listStockMovements()), + percent(4.0).then(listStockMovementsByStock()), + percent(4.0).then(listStockMovementsByBatch()), + percent(4.0).then(listStockMovementsByDateRange()), + percent(7.0).then(reserveAndConfirmStock()), + percent(6.0).then(recordStockMovement()), + percent(5.0).then(listInventoryCounts()), + percent(5.0).then(getRandomInventoryCount()), + percent(4.0).then(createAndStartInventoryCount()), + percent(4.0).then(recordCountItem()) ).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 0272c31..960657f 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/simulation/FullWorkloadSimulation.java @@ -105,6 +105,8 @@ public class FullWorkloadSimulation extends Simulation { details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35), details("Bestandsbewegungen nach Charge").responseTime().mean().lt(35), details("Bestandsbewegungen nach Zeitraum").responseTime().mean().lt(35), + details("Inventuren auflisten").responseTime().mean().lt(35), + details("Inventur laden").responseTime().mean().lt(20), // Listen mit viel Daten (50-300 Einträge): mean < 75ms details("Chargen auflisten").responseTime().mean().lt(75), @@ -121,6 +123,9 @@ public class FullWorkloadSimulation extends Simulation { details("Produktionsauftrag stornieren").responseTime().mean().lt(50), details("Produktionsauftrag umterminieren").responseTime().mean().lt(50), details("Bestandsbewegung erfassen").responseTime().mean().lt(50), + details("Inventur anlegen").responseTime().mean().lt(50), + details("Inventur starten").responseTime().mean().lt(50), + details("Ist-Menge erfassen").responseTime().mean().lt(50), // Produktionsaufträge-Listen: mean < 35ms details("Produktionsaufträge auflisten").responseTime().mean().lt(35),