1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:09:35 +01:00

feat(inventory): Inventur durchführen – Ist-Mengen erfassen (US-6.2)

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.
This commit is contained in:
Sebastian Frick 2026-02-26 19:53:43 +01:00
parent 206921d2a6
commit 252f48d52b
17 changed files with 1451 additions and 17 deletions

View file

@ -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<InventoryCountError, InventoryCount> 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);
});
}
}

View file

@ -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<InventoryCountError, InventoryCount> 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);
});
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record RecordCountItemCommand(
String inventoryCountId,
String countItemId,
String actualQuantityAmount,
String actualQuantityUnit
) {}

View file

@ -89,6 +89,10 @@ public class CountItem {
return actualQuantity.amount().subtract(expectedQuantity.amount()); return actualQuantity.amount().subtract(expectedQuantity.amount());
} }
void recordActualQuantity(Quantity actualQuantity) {
this.actualQuantity = actualQuantity;
}
public boolean isCounted() { public boolean isCounted() {
return actualQuantity != null; return actualQuantity != null;
} }

View file

@ -1,8 +1,10 @@
package de.effigenix.domain.inventory; package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.article.ArticleId; import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
@ -21,6 +23,8 @@ import java.util.Objects;
* - Max. one active count (OPEN/COUNTING) per StorageLocation (Application Layer) * - Max. one active count (OPEN/COUNTING) per StorageLocation (Application Layer)
* - ArticleId unique within countItems * - ArticleId unique within countItems
* - addCountItem only in status OPEN * - addCountItem only in status OPEN
* - startCounting only in status OPEN, requires non-empty countItems
* - updateCountItem only in status COUNTING
*/ */
public class InventoryCount { public class InventoryCount {
@ -28,7 +32,7 @@ public class InventoryCount {
private final StorageLocationId storageLocationId; private final StorageLocationId storageLocationId;
private final LocalDate countDate; private final LocalDate countDate;
private final String initiatedBy; private final String initiatedBy;
private final InventoryCountStatus status; private InventoryCountStatus status;
private final Instant createdAt; private final Instant createdAt;
private final List<CountItem> countItems; private final List<CountItem> countItems;
@ -110,7 +114,7 @@ public class InventoryCount {
public Result<InventoryCountError, CountItem> addCountItem(CountItemDraft draft) { public Result<InventoryCountError, CountItem> addCountItem(CountItemDraft draft) {
if (status != InventoryCountStatus.OPEN) { if (status != InventoryCountStatus.OPEN) {
return Result.failure(new InventoryCountError.InvalidStatusTransition( return Result.failure(new InventoryCountError.InvalidStatusTransition(
status.name(), "addCountItem requires OPEN")); status.name(), InventoryCountStatus.OPEN.name()));
} }
CountItem item; CountItem item;
@ -127,6 +131,50 @@ public class InventoryCount {
return Result.success(item); return Result.success(item);
} }
// ==================== Status Transitions ====================
public Result<InventoryCountError, Void> 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<InventoryCountError, Void> 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 ==================== // ==================== Queries ====================
public boolean isActive() { public boolean isActive() {

View file

@ -55,6 +55,11 @@ public sealed interface InventoryCountError {
@Override public String message() { return "Not all count items have been counted"; } @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 { record CountItemNotFound(String id) implements InventoryCountError {
@Override public String code() { return "COUNT_ITEM_NOT_FOUND"; } @Override public String code() { return "COUNT_ITEM_NOT_FOUND"; }
@Override public String message() { return "Count item not found: " + id; } @Override public String message() { return "Count item not found: " + id; }

View file

@ -3,6 +3,8 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts; 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.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements; import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.ConfirmReservation;
@ -172,4 +174,14 @@ public class InventoryUseCaseConfiguration {
public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) { public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) {
return new ListInventoryCounts(inventoryCountRepository, 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);
}
} }

View file

@ -3,10 +3,14 @@ package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts; 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.CreateInventoryCountCommand;
import de.effigenix.application.inventory.command.RecordCountItemCommand;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse;
import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest;
import de.effigenix.shared.security.ActorId; import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -28,13 +32,19 @@ public class InventoryCountController {
private final CreateInventoryCount createInventoryCount; private final CreateInventoryCount createInventoryCount;
private final GetInventoryCount getInventoryCount; private final GetInventoryCount getInventoryCount;
private final ListInventoryCounts listInventoryCounts; private final ListInventoryCounts listInventoryCounts;
private final StartInventoryCount startInventoryCount;
private final RecordCountItem recordCountItem;
public InventoryCountController(CreateInventoryCount createInventoryCount, public InventoryCountController(CreateInventoryCount createInventoryCount,
GetInventoryCount getInventoryCount, GetInventoryCount getInventoryCount,
ListInventoryCounts listInventoryCounts) { ListInventoryCounts listInventoryCounts,
StartInventoryCount startInventoryCount,
RecordCountItem recordCountItem) {
this.createInventoryCount = createInventoryCount; this.createInventoryCount = createInventoryCount;
this.getInventoryCount = getInventoryCount; this.getInventoryCount = getInventoryCount;
this.listInventoryCounts = listInventoryCounts; this.listInventoryCounts = listInventoryCounts;
this.startInventoryCount = startInventoryCount;
this.recordCountItem = recordCountItem;
} }
@PostMapping @PostMapping
@ -92,6 +102,39 @@ public class InventoryCountController {
return ResponseEntity.ok(responses); return ResponseEntity.ok(responses);
} }
@PatchMapping("/{id}/start")
@PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')")
public ResponseEntity<InventoryCountResponse> 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<InventoryCountResponse> 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 ==================== // ==================== Exception Wrapper ====================
public static class InventoryCountDomainErrorException extends RuntimeException { public static class InventoryCountDomainErrorException extends RuntimeException {

View file

@ -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
) {}

View file

@ -56,6 +56,7 @@ public final class InventoryErrorHttpStatusMapper {
public static int toHttpStatus(InventoryCountError error) { public static int toHttpStatus(InventoryCountError error) {
return switch (error) { return switch (error) {
case InventoryCountError.InventoryCountNotFound e -> 404; case InventoryCountError.InventoryCountNotFound e -> 404;
case InventoryCountError.InvalidCountItemId e -> 400;
case InventoryCountError.CountItemNotFound e -> 404; case InventoryCountError.CountItemNotFound e -> 404;
case InventoryCountError.ActiveCountExists e -> 409; case InventoryCountError.ActiveCountExists e -> 409;
case InventoryCountError.DuplicateArticle e -> 409; case InventoryCountError.DuplicateArticle e -> 409;

View file

@ -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<Result<?, ?>>) 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);
}
}

View file

@ -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<Result<?, ?>>) 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);
}
}

View file

@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.*; 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<CountItem> 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 ==================== // ==================== CountItem deviation ====================
@Nested @Nested
@ -599,4 +825,20 @@ class InventoryCountTest {
new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1") new InventoryCountDraft("location-1", LocalDate.now().toString(), "user-1")
).unsafeGetValue(); ).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<CountItem> 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)
));
}
} }

View file

@ -12,7 +12,7 @@ import java.time.LocalDate;
import java.util.Set; import java.util.Set;
import java.util.UUID; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 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: * Abgedeckte Testfälle:
* - US-6.1 Inventur anlegen und Zählpositionen befüllen * - US-6.1 Inventur anlegen und Zählpositionen befüllen
* - US-6.2 Inventur durchführen (Ist-Mengen erfassen)
*/ */
@DisplayName("InventoryCount Controller Integration Tests") @DisplayName("InventoryCount Controller Integration Tests")
class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
@ -295,8 +296,253 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.length()").value(0)); .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 ==================== // ==================== 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 { private String createStorageLocation() throws Exception {
String json = """ String json = """
{"name": "Testlager-%s", "storageType": "DRY_STORAGE"} {"name": "Testlager-%s", "storageType": "DRY_STORAGE"}

View file

@ -44,6 +44,7 @@ public final class LoadTestDataSeeder {
private final List<String> stockIds = new ArrayList<>(); private final List<String> stockIds = new ArrayList<>();
private final List<String> stockBatchIds = new ArrayList<>(); private final List<String> stockBatchIds = new ArrayList<>();
private final List<String> stockMovementIds = new ArrayList<>(); private final List<String> stockMovementIds = new ArrayList<>();
private final List<String> inventoryCountIds = new ArrayList<>();
// Statische Felder für Zugriff aus Szenarien // Statische Felder für Zugriff aus Szenarien
private static List<String> seededCategoryIds; private static List<String> seededCategoryIds;
@ -57,6 +58,7 @@ public final class LoadTestDataSeeder {
private static List<String> seededStockIds; private static List<String> seededStockIds;
private static List<String> seededStockBatchIds; private static List<String> seededStockBatchIds;
private static List<String> seededStockMovementIds; private static List<String> seededStockMovementIds;
private static List<String> seededInventoryCountIds;
public LoadTestDataSeeder(ConfigurableApplicationContext appContext) { public LoadTestDataSeeder(ConfigurableApplicationContext appContext) {
int port = appContext.getEnvironment() int port = appContext.getEnvironment()
@ -80,6 +82,7 @@ public final class LoadTestDataSeeder {
seedProductionOrders(); seedProductionOrders();
seedStocksAndBatches(); seedStocksAndBatches();
seedStockMovements(); seedStockMovements();
seedInventoryCounts();
// Statische Referenzen setzen // Statische Referenzen setzen
seededCategoryIds = List.copyOf(categoryIds); seededCategoryIds = List.copyOf(categoryIds);
@ -93,16 +96,17 @@ public final class LoadTestDataSeeder {
seededStockIds = List.copyOf(stockIds); seededStockIds = List.copyOf(stockIds);
seededStockBatchIds = List.copyOf(stockBatchIds); seededStockBatchIds = List.copyOf(stockBatchIds);
seededStockMovementIds = List.copyOf(stockMovementIds); seededStockMovementIds = List.copyOf(stockMovementIds);
seededInventoryCountIds = List.copyOf(inventoryCountIds);
long duration = System.currentTimeMillis() - start; long duration = System.currentTimeMillis() - start;
System.out.printf( System.out.printf(
"Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, " "Seeded in %dms: %d Kategorien, %d Artikel, %d Lieferanten, %d Kunden, "
+ "%d Lagerorte, %d Rezepte, %d Chargen, %d Produktionsaufträge, " + "%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(), duration, categoryIds.size(), articleIds.size(), supplierIds.size(),
customerIds.size(), storageLocationIds.size(), recipeIds.size(), customerIds.size(), storageLocationIds.size(), recipeIds.size(),
batchIds.size(), productionOrderIds.size(), batchIds.size(), productionOrderIds.size(),
stockIds.size(), stockMovementIds.size()); stockIds.size(), stockMovementIds.size(), inventoryCountIds.size());
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Testdaten-Seeding fehlgeschlagen", 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 ---- // ---- HTTP Helper ----
private String post(String path, String jsonBody, String token) throws Exception { 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 ---- // ---- Statische Getter für Szenarien ----
public static List<String> categoryIds() { return seededCategoryIds; } public static List<String> categoryIds() { return seededCategoryIds; }
@ -689,4 +745,5 @@ public final class LoadTestDataSeeder {
public static List<String> stockIds() { return seededStockIds; } public static List<String> stockIds() { return seededStockIds; }
public static List<String> stockBatchIds() { return seededStockBatchIds; } public static List<String> stockBatchIds() { return seededStockBatchIds; }
public static List<String> stockMovementIds() { return seededStockMovementIds; } public static List<String> stockMovementIds() { return seededStockMovementIds; }
public static List<String> inventoryCountIds() { return seededInventoryCountIds; }
} }

View file

@ -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() { public static ChainBuilder recordStockMovement() {
return exec(session -> { return exec(session -> {
var rnd = ThreadLocalRandom.current(); var rnd = ThreadLocalRandom.current();
@ -189,17 +267,21 @@ public final class InventoryScenario {
.exec(AuthenticationScenario.login("admin", "admin123")) .exec(AuthenticationScenario.login("admin", "admin123"))
.repeat(15).on( .repeat(15).on(
randomSwitch().on( randomSwitch().on(
percent(18.0).then(listStocks()), percent(15.0).then(listStocks()),
percent(12.0).then(listStorageLocations()), percent(10.0).then(listStorageLocations()),
percent(12.0).then(getRandomStorageLocation()), percent(10.0).then(getRandomStorageLocation()),
percent(12.0).then(listStocksByLocation()), percent(10.0).then(listStocksByLocation()),
percent(8.0).then(listStocksBelowMinimum()), percent(6.0).then(listStocksBelowMinimum()),
percent(8.0).then(listStockMovements()), percent(6.0).then(listStockMovements()),
percent(5.0).then(listStockMovementsByStock()), percent(4.0).then(listStockMovementsByStock()),
percent(5.0).then(listStockMovementsByBatch()), percent(4.0).then(listStockMovementsByBatch()),
percent(5.0).then(listStockMovementsByDateRange()), percent(4.0).then(listStockMovementsByDateRange()),
percent(8.0).then(reserveAndConfirmStock()), percent(7.0).then(reserveAndConfirmStock()),
percent(7.0).then(recordStockMovement()) 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) ).pause(1, 3)
); );
} }

View file

@ -105,6 +105,8 @@ public class FullWorkloadSimulation extends Simulation {
details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35), details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35),
details("Bestandsbewegungen nach Charge").responseTime().mean().lt(35), details("Bestandsbewegungen nach Charge").responseTime().mean().lt(35),
details("Bestandsbewegungen nach Zeitraum").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 // Listen mit viel Daten (50-300 Einträge): mean < 75ms
details("Chargen auflisten").responseTime().mean().lt(75), 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 stornieren").responseTime().mean().lt(50),
details("Produktionsauftrag umterminieren").responseTime().mean().lt(50), details("Produktionsauftrag umterminieren").responseTime().mean().lt(50),
details("Bestandsbewegung erfassen").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 // Produktionsaufträge-Listen: mean < 35ms
details("Produktionsaufträge auflisten").responseTime().mean().lt(35), details("Produktionsaufträge auflisten").responseTime().mean().lt(35),