1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:29:34 +01:00

feat(inventory): Inventur abschließen mit Ausgleichsbuchungen (US-6.3)

Vier-Augen-Prinzip (completedBy ≠ initiatedBy), Vollständigkeitsprüfung
aller CountItems, und automatische ADJUSTMENT-StockMovements für
Abweichungen (IN bei Ist > Soll, OUT bei Ist < Soll).

Domain: complete()-Methode, InventoryCountReconciliationService
Application: CompleteInventoryCount UseCase
Infrastruktur: POST /{id}/complete Endpoint, Liquibase-Migration

Closes #19
This commit is contained in:
Sebastian Frick 2026-03-18 12:56:31 +01:00
parent 6996a301f9
commit e4f4537581
21 changed files with 1373 additions and 26 deletions

View file

@ -0,0 +1,394 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CompleteInventoryCountCommand;
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.ArrayList;
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("CompleteInventoryCount Use Case")
class CompleteInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private StockRepository stockRepository;
@Mock private StockMovementRepository stockMovementRepository;
@Mock private UnitOfWork unitOfWork;
@Mock private AuthorizationPort authPort;
private CompleteInventoryCount completeInventoryCount;
private InventoryCountReconciliationService reconciliationService;
private final ActorId actorId = ActorId.of("user-2");
@BeforeEach
void setUp() {
reconciliationService = new InventoryCountReconciliationService();
completeInventoryCount = new CompleteInventoryCount(
inventoryCountRepository, stockRepository, stockMovementRepository,
reconciliationService, 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();
});
}
// ==================== Happy Path ====================
@Test
@DisplayName("should complete inventory count with adjustment movements")
void shouldCompleteWithAdjustments() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "8.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.COMPLETED);
assertThat(result.unsafeGetValue().completedBy()).isEqualTo("user-2");
verify(inventoryCountRepository).save(any(InventoryCount.class));
verify(stockMovementRepository).save(any(StockMovement.class));
}
@Test
@DisplayName("should complete with no movements when no deviations")
void shouldCompleteWithNoMovementsWhenNoDeviations() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "10.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.COMPLETED);
verify(inventoryCountRepository).save(any(InventoryCount.class));
verify(stockMovementRepository, never()).save(any());
}
@Test
@DisplayName("should create IN movement for positive deviation")
void shouldCreateInMovementForPositiveDeviation() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "15.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
verify(stockMovementRepository).save(argThat(movement ->
movement.direction() == MovementDirection.IN
&& movement.quantity().amount().compareTo(new BigDecimal("5.0")) == 0
&& movement.movementType() == MovementType.ADJUSTMENT
));
}
@Test
@DisplayName("should create OUT movement for negative deviation")
void shouldCreateOutMovementForNegativeDeviation() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "7.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
verify(stockMovementRepository).save(argThat(movement ->
movement.direction() == MovementDirection.OUT
&& movement.quantity().amount().compareTo(new BigDecimal("3.0")) == 0
));
}
// ==================== Authorization ====================
@Test
@DisplayName("should fail when not authorized")
void shouldFailWhenNotAuthorized() {
when(authPort.can(any(ActorId.class), any())).thenReturn(false);
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
verify(inventoryCountRepository, never()).findById(any());
}
// ==================== Input Validation ====================
@Test
@DisplayName("should fail when inventory count ID is blank")
void shouldFailWhenIdBlank() {
var cmd = new CompleteInventoryCountCommand("", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
@Test
@DisplayName("should fail when inventory count ID is null")
void shouldFailWhenIdNull() {
var cmd = new CompleteInventoryCountCommand(null, "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
// ==================== Not Found ====================
@Test
@DisplayName("should fail when inventory count not found")
void shouldFailWhenNotFound() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty()));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
// ==================== Domain Validation Errors ====================
@Test
@DisplayName("should fail when same person violation (Vier-Augen-Prinzip)")
void shouldFailWhenSamePerson() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
// completedBy = initiatedBy = "user-1"
var cmd = new CompleteInventoryCountCommand("count-1", "user-1");
var result = completeInventoryCount.execute(cmd, ActorId.of("user-1"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.SamePersonViolation.class);
}
@Test
@DisplayName("should fail when count items are incomplete")
void shouldFailWhenIncompleteItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when count is not in COUNTING status")
void shouldFailWhenWrongStatus() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), new ArrayList<>()
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
// ==================== Repository Failures ====================
@Test
@DisplayName("should fail when findById repository fails")
void shouldFailWhenFindByIdFails() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock repository fails")
void shouldFailWhenStockRepositoryFails() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db error")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when inventory count save fails")
void shouldFailWhenSaveFails() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "10.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock movement save fails")
void shouldFailWhenMovementSaveFails() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "8.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("movement save failed")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock not found for deviating article")
void shouldFailWhenStockNotFoundForArticle() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of()));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
// ==================== Helpers ====================
private InventoryCount createCountingCountAllCounted(String expected, String actual) {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal(expected), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal(actual), UnitOfMeasure.KILOGRAM))
));
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
}
private Stock stockFor(String articleId, String stockId) {
var batch = StockBatch.reconstitute(
StockBatchId.of("batch-1"),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30),
StockBatchStatus.AVAILABLE,
Instant.now()
);
return Stock.reconstitute(
StockId.of(stockId), ArticleId.of(articleId),
StorageLocationId.of("location-1"), null, null,
List.of(batch), List.of()
);
}
}

View file

@ -42,6 +42,7 @@ class GetInventoryCountTest {
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
null,
InventoryCountStatus.OPEN,
Instant.now(),
List.of()

View file

@ -42,6 +42,7 @@ class ListInventoryCountsTest {
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
null,
InventoryCountStatus.OPEN,
Instant.now(),
List.of()
@ -52,6 +53,7 @@ class ListInventoryCountsTest {
StorageLocationId.of("location-2"),
LocalDate.now(),
"user-1",
null,
InventoryCountStatus.COMPLETED,
Instant.now(),
List.of()

View file

@ -55,7 +55,7 @@ class RecordCountItemTest {
private InventoryCount createCountingCount() {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
LocalDate.now(), "user-1", null, 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),
@ -226,7 +226,7 @@ class RecordCountItemTest {
void shouldFailWhenStatusIsOpen() {
var openCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
LocalDate.now(), "user-1", null, 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)

View file

@ -58,7 +58,7 @@ class StartInventoryCountTest {
);
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), items
);
}
@ -110,7 +110,7 @@ class StartInventoryCountTest {
void shouldFailWhenNoItems() {
var emptyCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
@ -128,7 +128,7 @@ class StartInventoryCountTest {
void shouldFailWhenAlreadyCounting() {
var countingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
LocalDate.now(), "user-1", null, 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)

View file

@ -46,6 +46,17 @@ class InventoryCountFuzzTest {
}
}
}
// Exercise state transitions with fuzzed input
switch (count.startCounting()) {
case Result.Failure(var err) -> { }
case Result.Success(var ignored) -> {
// Try to complete with fuzzed completedBy
switch (count.complete(data.consumeString(50))) {
case Result.Failure(var err2) -> { }
case Result.Success(var ignored2) -> { }
}
}
}
// Verify aggregate getters don't throw
count.isActive();
count.countItems();
@ -54,6 +65,7 @@ class InventoryCountFuzzTest {
count.countDate();
count.status();
count.initiatedBy();
count.completedBy();
count.createdAt();
}
}

View file

@ -0,0 +1,271 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.assertThat;
@DisplayName("InventoryCountReconciliationService")
class InventoryCountReconciliationServiceTest {
private InventoryCountReconciliationService service;
@BeforeEach
void setUp() {
service = new InventoryCountReconciliationService();
}
@Test
@DisplayName("should return empty list when no deviations")
void shouldReturnEmptyWhenNoDeviations() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should create IN adjustment for positive deviation (actual > expected)")
void shouldCreateInAdjustmentForPositiveDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
var draft = drafts.getFirst();
assertThat(draft.direction()).isEqualTo("IN");
assertThat(draft.quantityAmount()).isEqualTo("5.0");
assertThat(draft.movementType()).isEqualTo("ADJUSTMENT");
assertThat(draft.stockId()).isEqualTo("stock-1");
assertThat(draft.articleId()).isEqualTo("article-1");
}
@Test
@DisplayName("should create OUT adjustment for negative deviation (actual < expected)")
void shouldCreateOutAdjustmentForNegativeDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "7.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
var draft = drafts.getFirst();
assertThat(draft.direction()).isEqualTo("OUT");
assertThat(draft.quantityAmount()).isEqualTo("3.0");
}
@Test
@DisplayName("should skip items with zero deviation")
void shouldSkipZeroDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0"),
countedItem("item-2", "article-2", "5.0", "8.0")
));
var stocks = List.of(
stockFor("article-1", "stock-1"),
stockFor("article-2", "stock-2")
);
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
assertThat(drafts.getFirst().articleId()).isEqualTo("article-2");
}
@Test
@DisplayName("should create multiple adjustments for multiple deviating items")
void shouldCreateMultipleAdjustments() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0"),
countedItem("item-2", "article-2", "5.0", "3.0"),
countedItem("item-3", "article-3", "20.0", "20.0")
));
var stocks = List.of(
stockFor("article-1", "stock-1"),
stockFor("article-2", "stock-2"),
stockFor("article-3", "stock-3")
);
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(2);
assertThat(drafts.get(0).direction()).isEqualTo("IN");
assertThat(drafts.get(0).quantityAmount()).isEqualTo("2.0");
assertThat(drafts.get(1).direction()).isEqualTo("OUT");
assertThat(drafts.get(1).quantityAmount()).isEqualTo("2.0");
}
@Test
@DisplayName("should fail when stock not found for deviating article")
void shouldFailWhenStockNotFound() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var result = service.reconcile(count, List.of(), "user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
@Test
@DisplayName("should fail when stock has no batches for deviating article")
void shouldFailWhenStockHasNoBatches() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var emptyStock = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), null, null,
List.of(), List.of()
);
var result = service.reconcile(count, List.of(emptyStock), "user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
@Test
@DisplayName("should not fail for zero-deviation item even without stock")
void shouldNotFailForZeroDeviationWithoutStock() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0")
));
var result = service.reconcile(count, List.of(), "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should use correct batch data in draft")
void shouldUseCorrectBatchData() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var draft = result.unsafeGetValue().getFirst();
assertThat(draft.stockBatchId()).isEqualTo("batch-1");
assertThat(draft.batchId()).isEqualTo("BATCH-001");
assertThat(draft.batchType()).isEqualTo("PRODUCED");
}
@Test
@DisplayName("should set correct reason containing inventory count ID")
void shouldSetCorrectReason() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().reason()).contains("count-1");
}
@Test
@DisplayName("should set performedBy from parameter")
void shouldSetPerformedBy() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "completer-user");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().performedBy()).isEqualTo("completer-user");
}
@Test
@DisplayName("should use correct unit of measure from count item")
void shouldUseCorrectUom() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.LITER),
Quantity.reconstitute(new BigDecimal("12.0"), UnitOfMeasure.LITER))
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), items
);
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().quantityUnit()).isEqualTo("LITER");
}
// ==================== Helpers ====================
private InventoryCount createCompletedCount(List<CountItem> items) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), new ArrayList<>(items)
);
}
private CountItem countedItem(String itemId, String articleId, String expected, String actual) {
return CountItem.reconstitute(
CountItemId.of(itemId), ArticleId.of(articleId),
Quantity.reconstitute(new BigDecimal(expected), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal(actual), UnitOfMeasure.KILOGRAM)
);
}
private Stock stockFor(String articleId, String stockId) {
var batch = StockBatch.reconstitute(
StockBatchId.of("batch-1"),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30),
StockBatchStatus.AVAILABLE,
Instant.now()
);
return Stock.reconstitute(
StockId.of(stockId), ArticleId.of(articleId),
StorageLocationId.of("location-1"), null, null,
List.of(batch), List.of()
);
}
}

View file

@ -258,6 +258,7 @@ class InventoryCountTest {
StorageLocationId.of("location-1"),
LocalDate.now(),
"user-1",
null,
InventoryCountStatus.COUNTING,
java.time.Instant.now(),
java.util.List.of()
@ -381,7 +382,7 @@ class InventoryCountTest {
private InventoryCount reconstitute(InventoryCountStatus status) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", status,
LocalDate.now(), "user-1", null, status,
Instant.now(), List.of()
);
}
@ -452,7 +453,7 @@ class InventoryCountTest {
private InventoryCount reconstitute(InventoryCountStatus status, List<CountItem> items) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", status,
LocalDate.now(), "user-1", null, status,
Instant.now(), items
);
}
@ -525,7 +526,7 @@ class InventoryCountTest {
var items = createCountItems();
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED,
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), items
);
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
@ -599,7 +600,7 @@ class InventoryCountTest {
);
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
@ -702,7 +703,7 @@ class InventoryCountTest {
void countingShouldBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of()
);
assertThat(count.isActive()).isTrue();
@ -713,7 +714,7 @@ class InventoryCountTest {
void completedShouldNotBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED,
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
@ -724,7 +725,7 @@ class InventoryCountTest {
void cancelledShouldNotBeActive() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.CANCELLED,
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), List.of()
);
assertThat(count.isActive()).isFalse();
@ -753,7 +754,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.of(2025, 6, 15), "user-1", InventoryCountStatus.COUNTING,
LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
@ -787,12 +788,12 @@ class InventoryCountTest {
void sameIdShouldBeEqual() {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
LocalDate.now(), "user-a", InventoryCountStatus.OPEN,
LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
LocalDate.now().minusDays(1), "user-b", InventoryCountStatus.COMPLETED,
LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of()
);
@ -805,12 +806,12 @@ class InventoryCountTest {
void differentIdShouldNotBeEqual() {
var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
var b = InventoryCount.reconstitute(
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of()
);
@ -818,6 +819,210 @@ class InventoryCountTest {
}
}
// ==================== complete ====================
@Nested
@DisplayName("complete()")
class Complete {
@Test
@DisplayName("should transition from COUNTING to COMPLETED")
void shouldCompleteWhenValid() {
var count = createCountingCountAllCounted();
var result = count.complete("user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.COMPLETED);
assertThat(count.completedBy()).isEqualTo("user-2");
}
@Test
@DisplayName("should not be active after completion")
void shouldNotBeActiveAfterCompletion() {
var count = createCountingCountAllCounted();
count.complete("user-2");
assertThat(count.isActive()).isFalse();
}
@Test
@DisplayName("should complete with multiple counted items")
void shouldCompleteWithMultipleItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)),
CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER))
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
var result = count.complete("user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.COMPLETED);
}
@Test
@DisplayName("should fail when status is OPEN")
void shouldFailWhenStatusIsOpen() {
var count = createOpenCount();
count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when status is COMPLETED")
void shouldFailWhenStatusIsCompleted() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), createCountedItems()
);
var result = count.complete("user-3");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when status is CANCELLED")
void shouldFailWhenStatusIsCancelled() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), createCountedItems()
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when completedBy is null")
void shouldFailWhenCompletedByNull() {
var count = createCountingCountAllCounted();
var result = count.complete(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when completedBy is blank")
void shouldFailWhenCompletedByBlank() {
var count = createCountingCountAllCounted();
var result = count.complete(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when no count items exist")
void shouldFailWhenNoCountItems() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), new ArrayList<>()
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.NoCountItems.class);
}
@Test
@DisplayName("should fail when not all items have been counted")
void shouldFailWhenIncompleteItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)),
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", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when all items are uncounted")
void shouldFailWhenAllItemsUncounted() {
var count = createCountingCount();
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when completedBy equals initiatedBy (Vier-Augen-Prinzip)")
void shouldFailWhenSamePerson() {
var count = createCountingCountAllCounted();
var result = count.complete("user-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.SamePersonViolation.class);
}
@Test
@DisplayName("should not modify status when validation fails")
void shouldNotModifyStatusOnFailure() {
var count = createCountingCountAllCounted();
count.complete("user-1"); // Same person fails
assertThat(count.status()).isEqualTo(InventoryCountStatus.COUNTING);
assertThat(count.completedBy()).isNull();
}
private InventoryCount createCountingCountAllCounted() {
var items = createCountedItems();
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
}
private List<CountItem> createCountedItems() {
return new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM))
));
}
}
// ==================== Helpers ====================
private InventoryCount createOpenCount() {
@ -830,7 +1035,7 @@ class InventoryCountTest {
var items = createCountItems();
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
}

View file

@ -5,6 +5,7 @@ import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@ -22,11 +23,13 @@ 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)
* - US-6.3 Inventur abschließen mit Ausgleichsbuchungen
*/
@DisplayName("InventoryCount Controller Integration Tests")
class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken;
private String completerToken;
private String viewerToken;
private String storageLocationId;
@ -36,9 +39,11 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
String adminId = createUser("ic.admin", "ic.admin@test.com", Set.of(adminRoleId), "BRANCH-01");
String completerId = createUser("ic.completer", "ic.completer@test.com", Set.of(adminRoleId), "BRANCH-01");
String viewerId = createUser("ic.viewer", "ic.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01");
adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ");
adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_READ");
completerToken = generateToken(completerId, "ic.completer", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_READ");
viewerToken = generateToken(viewerId, "ic.viewer", "USER_READ");
storageLocationId = createStorageLocation();
@ -515,6 +520,181 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(status().isBadRequest());
}
// ==================== US-6.3: Inventur abschließen ====================
@Nested
@DisplayName("US-6.3: Inventur abschließen")
class CompleteInventoryCount {
@Test
@DisplayName("Inventur abschließen → 200 mit COMPLETED Status und completedBy")
void completeInventoryCount_returns200() throws Exception {
String countId = createAndStartCountWithRecordedItems();
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(countId))
.andExpect(jsonPath("$.status").value("COMPLETED"))
.andExpect(jsonPath("$.completedBy").isNotEmpty());
}
@Test
@DisplayName("Inventur abschließen erzeugt ADJUSTMENT StockMovements")
void completeInventoryCount_createsAdjustmentMovements() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Abschließen
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk());
// StockMovements prüfen mindestens ein ADJUSTMENT
mockMvc.perform(get("/api/inventory/stock-movements")
.param("movementType", "ADJUSTMENT")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(1)))
.andExpect(jsonPath("$[0].movementType").value("ADJUSTMENT"));
}
@Test
@DisplayName("Vier-Augen-Prinzip: gleiche Person → 409 SAME_PERSON_VIOLATION")
void completeInventoryCount_samePerson_returns409() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Gleicher User, der die Inventur initiiert hat
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("SAME_PERSON_VIOLATION"));
}
@Test
@DisplayName("Inventur mit unvollständigen Items abschließen → 409 INCOMPLETE_COUNT_ITEMS")
void completeInventoryCount_incompleteItems_returns409() throws Exception {
String countId = createInventoryCountWithStock();
// Starten, aber keine Ist-Mengen erfassen
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INCOMPLETE_COUNT_ITEMS"));
}
@Test
@DisplayName("OPEN Inventur abschließen → 409 INVALID_STATUS_TRANSITION")
void completeInventoryCount_openStatus_returns409() throws Exception {
String countId = createInventoryCountWithStock();
// Nicht starten bleibt OPEN
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Unbekannte ID → 404 INVENTORY_COUNT_NOT_FOUND")
void completeInventoryCount_notFound_returns404() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("INVENTORY_COUNT_NOT_FOUND"));
}
@Test
@DisplayName("Ohne Token → 401")
void completeInventoryCount_noToken_returns401() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne INVENTORY_COUNT_WRITE → 403")
void completeInventoryCount_noPermission_returns403() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Bereits abgeschlossene Inventur nochmal abschließen → 409")
void completeInventoryCount_alreadyCompleted_returns409() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Erster Abschluss
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk());
// Zweiter Abschluss
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Inventur ohne Abweichung → 200, keine StockMovements erzeugt")
void completeInventoryCount_noDeviation_noMovements() throws Exception {
String countId = createAndStartCountWithExactItems();
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("COMPLETED"));
}
private String createAndStartCountWithRecordedItems() 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);
// Abweichung: expected 25.0, actual 20.0
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());
return countId;
}
private String createAndStartCountWithExactItems() 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);
// Keine Abweichung: expected 25.0, actual 25.0
String body = """
{"actualQuantityAmount": "25.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());
return countId;
}
}
// ==================== Helpers ====================
private String createInventoryCountWithStock() throws Exception {