mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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:
parent
206921d2a6
commit
252f48d52b
17 changed files with 1451 additions and 17 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record RecordCountItemCommand(
|
||||
String inventoryCountId,
|
||||
String countItemId,
|
||||
String actualQuantityAmount,
|
||||
String actualQuantityUnit
|
||||
) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CountItem> countItems;
|
||||
|
||||
|
|
@ -110,7 +114,7 @@ public class InventoryCount {
|
|||
public Result<InventoryCountError, CountItem> 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<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 ====================
|
||||
|
||||
public boolean isActive() {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 ====================
|
||||
|
||||
public static class InventoryCountDomainErrorException extends RuntimeException {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 ====================
|
||||
|
||||
@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<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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue