diff --git a/backend/src/main/java/de/effigenix/application/inventory/CompleteInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/CompleteInventoryCount.java new file mode 100644 index 0000000..33dfcc9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/CompleteInventoryCount.java @@ -0,0 +1,114 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CompleteInventoryCountCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.persistence.UnitOfWork; +import de.effigenix.shared.security.ActorId; +import de.effigenix.shared.security.AuthorizationPort; + +import java.util.List; + +public class CompleteInventoryCount { + + private final InventoryCountRepository inventoryCountRepository; + private final StockRepository stockRepository; + private final StockMovementRepository stockMovementRepository; + private final InventoryCountReconciliationService reconciliationService; + private final UnitOfWork unitOfWork; + private final AuthorizationPort authPort; + + public CompleteInventoryCount(InventoryCountRepository inventoryCountRepository, + StockRepository stockRepository, + StockMovementRepository stockMovementRepository, + InventoryCountReconciliationService reconciliationService, + UnitOfWork unitOfWork, + AuthorizationPort authPort) { + this.inventoryCountRepository = inventoryCountRepository; + this.stockRepository = stockRepository; + this.stockMovementRepository = stockMovementRepository; + this.reconciliationService = reconciliationService; + this.unitOfWork = unitOfWork; + this.authPort = authPort; + } + + public Result execute(CompleteInventoryCountCommand cmd, ActorId actorId) { + if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to complete inventory counts")); + } + + 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())); + } + + // 1. InventoryCount laden + 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(); + } + } + + // 2. Inventur abschließen (Vier-Augen-Prinzip, Vollständigkeit) + switch (count.complete(cmd.completedBy())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 3. Stocks für StorageLocation laden + List stocks; + switch (stockRepository.findAllByStorageLocationId(count.storageLocationId())) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var val) -> stocks = val; + } + + // 4. Reconciliation: Ausgleichsbuchungen erzeugen + List movementDrafts; + switch (reconciliationService.reconcile(count, stocks, cmd.completedBy())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> movementDrafts = val; + } + + // 5. StockMovements erzeugen und validieren + List movements = new java.util.ArrayList<>(); + for (StockMovementDraft draft : movementDrafts) { + switch (StockMovement.record(draft)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var val) -> movements.add(val); + } + } + + // 6. Atomar speichern + 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) -> { } + } + + for (StockMovement movement : movements) { + switch (stockMovementRepository.save(movement)) { + case Result.Failure(var err) -> + { return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + } + + return Result.success(count); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/CompleteInventoryCountCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/CompleteInventoryCountCommand.java new file mode 100644 index 0000000..139b1b3 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/CompleteInventoryCountCommand.java @@ -0,0 +1,6 @@ +package de.effigenix.application.inventory.command; + +public record CompleteInventoryCountCommand( + String inventoryCountId, + String completedBy +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java index 31bd003..3f2d5fd 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java @@ -25,6 +25,7 @@ import java.util.Objects; * - addCountItem only in status OPEN * - startCounting only in status OPEN, requires non-empty countItems * - updateCountItem only in status COUNTING + * - complete only in status COUNTING, requires all items counted, Vier-Augen-Prinzip (completedBy ≠ initiatedBy) */ public class InventoryCount { @@ -32,6 +33,7 @@ public class InventoryCount { private final StorageLocationId storageLocationId; private final LocalDate countDate; private final String initiatedBy; + private String completedBy; private InventoryCountStatus status; private final Instant createdAt; private final List countItems; @@ -41,6 +43,7 @@ public class InventoryCount { StorageLocationId storageLocationId, LocalDate countDate, String initiatedBy, + String completedBy, InventoryCountStatus status, Instant createdAt, List countItems @@ -49,6 +52,7 @@ public class InventoryCount { this.storageLocationId = storageLocationId; this.countDate = countDate; this.initiatedBy = initiatedBy; + this.completedBy = completedBy; this.status = status; this.createdAt = createdAt; this.countItems = new ArrayList<>(countItems); @@ -90,7 +94,7 @@ public class InventoryCount { return Result.success(new InventoryCount( InventoryCountId.generate(), storageLocationId, countDate, - draft.initiatedBy(), InventoryCountStatus.OPEN, Instant.now(), List.of() + draft.initiatedBy(), null, InventoryCountStatus.OPEN, Instant.now(), List.of() )); } @@ -102,11 +106,12 @@ public class InventoryCount { StorageLocationId storageLocationId, LocalDate countDate, String initiatedBy, + String completedBy, InventoryCountStatus status, Instant createdAt, List countItems ) { - return new InventoryCount(id, storageLocationId, countDate, initiatedBy, status, createdAt, countItems); + return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, createdAt, countItems); } // ==================== Count Item Management ==================== @@ -145,6 +150,29 @@ public class InventoryCount { return Result.success(null); } + public Result complete(String completedBy) { + if (status != InventoryCountStatus.COUNTING) { + return Result.failure(new InventoryCountError.InvalidStatusTransition( + status.name(), InventoryCountStatus.COMPLETED.name())); + } + if (completedBy == null || completedBy.isBlank()) { + return Result.failure(new InventoryCountError.InvalidInitiatedBy("completedBy must not be blank")); + } + if (countItems.isEmpty()) { + return Result.failure(new InventoryCountError.NoCountItems()); + } + boolean allCounted = countItems.stream().allMatch(CountItem::isCounted); + if (!allCounted) { + return Result.failure(new InventoryCountError.IncompleteCountItems()); + } + if (completedBy.equals(initiatedBy)) { + return Result.failure(new InventoryCountError.SamePersonViolation()); + } + this.completedBy = completedBy; + this.status = InventoryCountStatus.COMPLETED; + return Result.success(null); + } + public Result updateCountItem(CountItemId itemId, Quantity actualQuantity) { if (status != InventoryCountStatus.COUNTING) { return Result.failure(new InventoryCountError.InvalidStatusTransition( @@ -187,6 +215,7 @@ public class InventoryCount { public StorageLocationId storageLocationId() { return storageLocationId; } public LocalDate countDate() { return countDate; } public String initiatedBy() { return initiatedBy; } + public String completedBy() { return completedBy; } public InventoryCountStatus status() { return status; } public Instant createdAt() { return createdAt; } public List countItems() { return Collections.unmodifiableList(countItems); } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java index 0d661d3..dfd1b7e 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java @@ -85,6 +85,11 @@ public sealed interface InventoryCountError { @Override public String message() { return "An active inventory count already exists for storage location: " + storageLocationId; } } + record StockNotFoundForArticle(String articleId) implements InventoryCountError { + @Override public String code() { return "STOCK_NOT_FOUND_FOR_ARTICLE"; } + @Override public String message() { return "No stock found for article: " + articleId; } + } + record Unauthorized(String message) implements InventoryCountError { @Override public String code() { return "UNAUTHORIZED"; } } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountReconciliationService.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountReconciliationService.java new file mode 100644 index 0000000..fe8f1d1 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountReconciliationService.java @@ -0,0 +1,67 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.domain.masterdata.article.ArticleId; +import de.effigenix.shared.common.Result; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Domain Service: Erzeugt ADJUSTMENT-StockMovementDrafts für Inventur-Abweichungen. + * + * Für jedes CountItem mit Abweichung (actual ≠ expected) wird ein StockMovementDraft erzeugt: + * - Positive Abweichung (Ist > Soll) → Direction IN + * - Negative Abweichung (Ist < Soll) → Direction OUT + * + * Wählt die erste verfügbare Charge des jeweiligen Stocks als Buchungsziel. + */ +public class InventoryCountReconciliationService { + + public Result> reconcile( + InventoryCount count, + List stocks, + String performedBy + ) { + Map stocksByArticle = stocks.stream() + .collect(Collectors.toMap(Stock::articleId, s -> s, (a, b) -> a)); + + List drafts = new ArrayList<>(); + + for (CountItem item : count.countItems()) { + BigDecimal deviation = item.deviation(); + if (deviation == null || deviation.compareTo(BigDecimal.ZERO) == 0) { + continue; + } + + Stock stock = stocksByArticle.get(item.articleId()); + if (stock == null || stock.batches().isEmpty()) { + return Result.failure(new InventoryCountError.StockNotFoundForArticle(item.articleId().value())); + } + + StockBatch batch = stock.batches().getFirst(); + + String direction = deviation.compareTo(BigDecimal.ZERO) > 0 ? "IN" : "OUT"; + BigDecimal absAmount = deviation.abs(); + + drafts.add(new StockMovementDraft( + stock.id().value(), + item.articleId().value(), + batch.id().value(), + batch.batchReference().batchId(), + batch.batchReference().batchType().name(), + "ADJUSTMENT", + direction, + absAmount.toPlainString(), + item.expectedQuantity().uom().name(), + "Inventur-Ausgleich: Inventur " + count.id().value(), + null, + performedBy + )); + } + + return Result.success(drafts); + } +} diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index 25a1163..d7aa126 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -1,5 +1,6 @@ package de.effigenix.infrastructure.config; +import de.effigenix.application.inventory.CompleteInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.ListInventoryCounts; @@ -28,6 +29,7 @@ import de.effigenix.application.inventory.GetStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.usermanagement.AuditLogger; +import de.effigenix.domain.inventory.InventoryCountReconciliationService; import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.domain.inventory.StockMovementRepository; import de.effigenix.domain.inventory.StockRepository; @@ -184,4 +186,19 @@ public class InventoryUseCaseConfiguration { public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort); } + + @Bean + public InventoryCountReconciliationService inventoryCountReconciliationService() { + return new InventoryCountReconciliationService(); + } + + @Bean + public CompleteInventoryCount completeInventoryCount(InventoryCountRepository inventoryCountRepository, + StockRepository stockRepository, + StockMovementRepository stockMovementRepository, + InventoryCountReconciliationService reconciliationService, + UnitOfWork unitOfWork, + AuthorizationPort authorizationPort) { + return new CompleteInventoryCount(inventoryCountRepository, stockRepository, stockMovementRepository, reconciliationService, unitOfWork, authorizationPort); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java index 123dc71..e63559a 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/persistence/repository/JdbcInventoryCountRepository.java @@ -101,7 +101,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { int rows = jdbc.sql(""" UPDATE inventory_counts SET storage_location_id = :storageLocationId, count_date = :countDate, - initiated_by = :initiatedBy, status = :status, created_at = :createdAt + initiated_by = :initiatedBy, completed_by = :completedBy, + status = :status, created_at = :createdAt WHERE id = :id """) .param("id", inventoryCount.id().value()) @@ -110,8 +111,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { if (rows == 0) { jdbc.sql(""" - INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, status, created_at) - VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :status, :createdAt) + INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, created_at) + VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :createdAt) """) .param("id", inventoryCount.id().value()) .params(countParams(inventoryCount)) @@ -134,6 +135,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { params.put("storageLocationId", count.storageLocationId().value()); params.put("countDate", count.countDate()); params.put("initiatedBy", count.initiatedBy()); + params.put("completedBy", count.completedBy()); params.put("status", count.status().name()); params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC)); return params; @@ -206,7 +208,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { return InventoryCount.reconstitute( count.id(), count.storageLocationId(), count.countDate(), - count.initiatedBy(), count.status(), count.createdAt(), items + count.initiatedBy(), count.completedBy(), count.status(), count.createdAt(), items ); } @@ -224,6 +226,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { StorageLocationId.of(rs.getString("storage_location_id")), rs.getObject("count_date", LocalDate.class), rs.getString("initiated_by"), + rs.getString("completed_by"), InventoryCountStatus.valueOf(rs.getString("status")), rs.getObject("created_at", OffsetDateTime.class).toInstant(), List.of() diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java index 36a7374..08c0329 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/InventoryCountController.java @@ -1,10 +1,12 @@ package de.effigenix.infrastructure.inventory.web.controller; +import de.effigenix.application.inventory.CompleteInventoryCount; 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.CompleteInventoryCountCommand; import de.effigenix.application.inventory.command.CreateInventoryCountCommand; import de.effigenix.application.inventory.command.RecordCountItemCommand; import de.effigenix.domain.inventory.InventoryCountError; @@ -34,17 +36,20 @@ public class InventoryCountController { private final ListInventoryCounts listInventoryCounts; private final StartInventoryCount startInventoryCount; private final RecordCountItem recordCountItem; + private final CompleteInventoryCount completeInventoryCount; public InventoryCountController(CreateInventoryCount createInventoryCount, GetInventoryCount getInventoryCount, ListInventoryCounts listInventoryCounts, StartInventoryCount startInventoryCount, - RecordCountItem recordCountItem) { + RecordCountItem recordCountItem, + CompleteInventoryCount completeInventoryCount) { this.createInventoryCount = createInventoryCount; this.getInventoryCount = getInventoryCount; this.listInventoryCounts = listInventoryCounts; this.startInventoryCount = startInventoryCount; this.recordCountItem = recordCountItem; + this.completeInventoryCount = completeInventoryCount; } @PostMapping @@ -135,6 +140,22 @@ public class InventoryCountController { return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); } + @PostMapping("/{id}/complete") + @PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')") + public ResponseEntity completeInventoryCount( + @PathVariable String id, + Authentication authentication + ) { + var cmd = new CompleteInventoryCountCommand(id, authentication.getName()); + var result = completeInventoryCount.execute(cmd, ActorId.of(authentication.getName())); + + if (result.isFailure()) { + throw new InventoryCountDomainErrorException(result.unsafeGetError()); + } + + return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); + } + // ==================== Exception Wrapper ==================== public static class InventoryCountDomainErrorException extends RuntimeException { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java index ba079c8..16d0c92 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/InventoryCountResponse.java @@ -11,6 +11,7 @@ public record InventoryCountResponse( String storageLocationId, LocalDate countDate, String initiatedBy, + String completedBy, String status, Instant createdAt, List countItems @@ -21,6 +22,7 @@ public record InventoryCountResponse( count.storageLocationId().value(), count.countDate(), count.initiatedBy(), + count.completedBy(), count.status().name(), count.createdAt(), count.countItems().stream() diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java index 1564a32..5794484 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/exception/InventoryErrorHttpStatusMapper.java @@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper { case InventoryCountError.NoCountItems e -> 409; case InventoryCountError.IncompleteCountItems e -> 409; case InventoryCountError.SamePersonViolation e -> 409; + case InventoryCountError.StockNotFoundForArticle e -> 409; case InventoryCountError.CountDateInFuture e -> 400; case InventoryCountError.InvalidStorageLocationId e -> 400; case InventoryCountError.InvalidCountDate e -> 400; diff --git a/backend/src/main/resources/db/changelog/changes/037-add-completed-by-to-inventory-counts.xml b/backend/src/main/resources/db/changelog/changes/037-add-completed-by-to-inventory-counts.xml new file mode 100644 index 0000000..1fefe21 --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/037-add-completed-by-to-inventory-counts.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index ef93ffe..03889c9 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -42,5 +42,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java new file mode 100644 index 0000000..fbe0164 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java @@ -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>) 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() + ); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java index dee000e..ce5eec7 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java @@ -42,6 +42,7 @@ class GetInventoryCountTest { StorageLocationId.of("location-1"), LocalDate.now(), "user-1", + null, InventoryCountStatus.OPEN, Instant.now(), List.of() diff --git a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java index 96f2d01..8a02b95 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java @@ -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() diff --git a/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java b/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java index 26ffb83..1637551 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java @@ -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) diff --git a/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java index a75a927..37e79d8 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java @@ -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) diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java index 34f8b58..b0d577e 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountFuzzTest.java @@ -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(); } } diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java new file mode 100644 index 0000000..fb040ff --- /dev/null +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java @@ -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 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() + ); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java index 5dfd949..4047a3f 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java @@ -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 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 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 ); } diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java index 94cee88..6bb9c15 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/InventoryCountControllerIntegrationTest.java @@ -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 {