diff --git a/backend/src/main/java/de/effigenix/application/inventory/CancelInventoryCount.java b/backend/src/main/java/de/effigenix/application/inventory/CancelInventoryCount.java new file mode 100644 index 0000000..899fdb9 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/CancelInventoryCount.java @@ -0,0 +1,70 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CancelInventoryCountCommand; +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; + +public class CancelInventoryCount { + + private final InventoryCountRepository inventoryCountRepository; + private final AuthorizationPort authorizationPort; + private final UnitOfWork unitOfWork; + + public CancelInventoryCount( + InventoryCountRepository inventoryCountRepository, + AuthorizationPort authorizationPort, + UnitOfWork unitOfWork + ) { + this.inventoryCountRepository = inventoryCountRepository; + this.authorizationPort = authorizationPort; + this.unitOfWork = unitOfWork; + } + + public Result execute(CancelInventoryCountCommand cmd, ActorId actorId) { + if (!authorizationPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) { + return Result.failure(new InventoryCountError.Unauthorized("Not authorized to cancel inventory counts")); + } + + if (cmd.inventoryCountId() == null || cmd.inventoryCountId().isBlank()) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank")); + } + + InventoryCountId countId; + try { + countId = InventoryCountId.of(cmd.inventoryCountId()); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage())); + } + + InventoryCount inventoryCount; + switch (inventoryCountRepository.findById(countId)) { + case Result.Failure(var err) -> { + return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); + } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new InventoryCountError.InventoryCountNotFound(cmd.inventoryCountId())); + } + inventoryCount = opt.get(); + } + } + + switch (inventoryCount.cancel(cmd.reason())) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + return unitOfWork.executeAtomically(() -> { + switch (inventoryCountRepository.save(inventoryCount)) { + case Result.Failure(var err) -> { + return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); + } + case Result.Success(var ignored) -> { } + } + return Result.success(inventoryCount); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java index 93ddd6b..55e7471 100644 --- a/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java +++ b/backend/src/main/java/de/effigenix/application/inventory/ListInventoryCounts.java @@ -18,11 +18,20 @@ public class ListInventoryCounts { this.authPort = authPort; } - public Result> execute(String storageLocationId, ActorId actorId) { + public Result> execute(String storageLocationId, String status, ActorId actorId) { if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) { return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts")); } + InventoryCountStatus parsedStatus = null; + if (status != null) { + try { + parsedStatus = InventoryCountStatus.valueOf(status); + } catch (IllegalArgumentException e) { + return Result.failure(new InventoryCountError.InvalidStatus(status)); + } + } + if (storageLocationId != null) { StorageLocationId locId; try { @@ -30,7 +39,18 @@ public class ListInventoryCounts { } catch (IllegalArgumentException e) { return Result.failure(new InventoryCountError.InvalidStorageLocationId(e.getMessage())); } - return mapResult(inventoryCountRepository.findByStorageLocationId(locId)); + var result = mapResult(inventoryCountRepository.findByStorageLocationId(locId)); + if (parsedStatus != null) { + final InventoryCountStatus filterStatus = parsedStatus; + return result.map(counts -> counts.stream() + .filter(c -> c.status() == filterStatus) + .toList()); + } + return result; + } + + if (parsedStatus != null) { + return mapResult(inventoryCountRepository.findByStatus(parsedStatus)); } return mapResult(inventoryCountRepository.findAll()); diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/CancelInventoryCountCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/CancelInventoryCountCommand.java new file mode 100644 index 0000000..634e09b --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/CancelInventoryCountCommand.java @@ -0,0 +1,3 @@ +package de.effigenix.application.inventory.command; + +public record CancelInventoryCountCommand(String inventoryCountId, String reason) {} 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 3f2d5fd..3db249b 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCount.java @@ -26,6 +26,7 @@ import java.util.Objects; * - 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) + * - cancel only in status OPEN or COUNTING, requires non-blank reason → CANCELLED */ public class InventoryCount { @@ -35,6 +36,7 @@ public class InventoryCount { private final String initiatedBy; private String completedBy; private InventoryCountStatus status; + private String cancellationReason; private final Instant createdAt; private final List countItems; @@ -45,6 +47,7 @@ public class InventoryCount { String initiatedBy, String completedBy, InventoryCountStatus status, + String cancellationReason, Instant createdAt, List countItems ) { @@ -54,6 +57,7 @@ public class InventoryCount { this.initiatedBy = initiatedBy; this.completedBy = completedBy; this.status = status; + this.cancellationReason = cancellationReason; this.createdAt = createdAt; this.countItems = new ArrayList<>(countItems); } @@ -94,7 +98,7 @@ public class InventoryCount { return Result.success(new InventoryCount( InventoryCountId.generate(), storageLocationId, countDate, - draft.initiatedBy(), null, InventoryCountStatus.OPEN, Instant.now(), List.of() + draft.initiatedBy(), null, InventoryCountStatus.OPEN, null, Instant.now(), List.of() )); } @@ -108,10 +112,11 @@ public class InventoryCount { String initiatedBy, String completedBy, InventoryCountStatus status, + String cancellationReason, Instant createdAt, List countItems ) { - return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, createdAt, countItems); + return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, cancellationReason, createdAt, countItems); } // ==================== Count Item Management ==================== @@ -173,6 +178,19 @@ public class InventoryCount { return Result.success(null); } + public Result cancel(String reason) { + if (reason == null || reason.isBlank()) { + return Result.failure(new InventoryCountError.CancellationReasonRequired()); + } + if (status != InventoryCountStatus.OPEN && status != InventoryCountStatus.COUNTING) { + return Result.failure(new InventoryCountError.InvalidStatusTransition( + status.name(), InventoryCountStatus.CANCELLED.name())); + } + this.cancellationReason = reason; + this.status = InventoryCountStatus.CANCELLED; + return Result.success(null); + } + public Result updateCountItem(CountItemId itemId, Quantity actualQuantity) { if (status != InventoryCountStatus.COUNTING) { return Result.failure(new InventoryCountError.InvalidStatusTransition( @@ -217,6 +235,7 @@ public class InventoryCount { public String initiatedBy() { return initiatedBy; } public String completedBy() { return completedBy; } public InventoryCountStatus status() { return status; } + public String cancellationReason() { return cancellationReason; } 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 dfd1b7e..ca90c1c 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountError.java @@ -90,6 +90,16 @@ public sealed interface InventoryCountError { @Override public String message() { return "No stock found for article: " + articleId; } } + record CancellationReasonRequired() implements InventoryCountError { + @Override public String code() { return "CANCELLATION_REASON_REQUIRED"; } + @Override public String message() { return "Cancellation reason is required"; } + } + + record InvalidStatus(String status) implements InventoryCountError { + @Override public String code() { return "INVALID_STATUS"; } + @Override public String message() { return "Invalid status: " + status; } + } + record Unauthorized(String message) implements InventoryCountError { @Override public String code() { return "UNAUTHORIZED"; } } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java index d64176b..0b5509f 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/InventoryCountRepository.java @@ -14,6 +14,8 @@ public interface InventoryCountRepository { Result> findByStorageLocationId(StorageLocationId storageLocationId); + Result> findByStatus(InventoryCountStatus status); + Result existsActiveByStorageLocationId(StorageLocationId storageLocationId); Result save(InventoryCount inventoryCount); 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 d7aa126..eb7ce3f 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.CancelInventoryCount; import de.effigenix.application.inventory.CompleteInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.GetInventoryCount; @@ -187,6 +188,11 @@ public class InventoryUseCaseConfiguration { return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort); } + @Bean + public CancelInventoryCount cancelInventoryCount(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { + return new CancelInventoryCount(inventoryCountRepository, authorizationPort, unitOfWork); + } + @Bean public InventoryCountReconciliationService inventoryCountReconciliationService() { return new InventoryCountReconciliationService(); 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 e63559a..4eb584c 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 @@ -77,6 +77,20 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { } } + @Override + public Result> findByStatus(InventoryCountStatus status) { + try { + var counts = jdbc.sql("SELECT * FROM inventory_counts WHERE status = :status ORDER BY created_at DESC") + .param("status", status.name()) + .query(this::mapCountRow) + .list(); + return Result.success(loadChildrenForAll(counts)); + } catch (Exception e) { + logger.warn("Database error in findByStatus", e); + return Result.failure(new RepositoryError.DatabaseError(e.getMessage())); + } + } + @Override public Result existsActiveByStorageLocationId(StorageLocationId storageLocationId) { try { @@ -102,7 +116,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { UPDATE inventory_counts SET storage_location_id = :storageLocationId, count_date = :countDate, initiated_by = :initiatedBy, completed_by = :completedBy, - status = :status, created_at = :createdAt + status = :status, cancellation_reason = :cancellationReason, + created_at = :createdAt WHERE id = :id """) .param("id", inventoryCount.id().value()) @@ -111,8 +126,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { if (rows == 0) { jdbc.sql(""" - 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) + INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, cancellation_reason, created_at) + VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :cancellationReason, :createdAt) """) .param("id", inventoryCount.id().value()) .params(countParams(inventoryCount)) @@ -137,6 +152,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { params.put("initiatedBy", count.initiatedBy()); params.put("completedBy", count.completedBy()); params.put("status", count.status().name()); + params.put("cancellationReason", count.cancellationReason()); params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC)); return params; } @@ -208,7 +224,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { return InventoryCount.reconstitute( count.id(), count.storageLocationId(), count.countDate(), - count.initiatedBy(), count.completedBy(), count.status(), count.createdAt(), items + count.initiatedBy(), count.completedBy(), count.status(), + count.cancellationReason(), count.createdAt(), items ); } @@ -228,6 +245,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository { rs.getString("initiated_by"), rs.getString("completed_by"), InventoryCountStatus.valueOf(rs.getString("status")), + rs.getString("cancellation_reason"), 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 5822df3..25c773f 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,15 +1,18 @@ package de.effigenix.infrastructure.inventory.web.controller; +import de.effigenix.application.inventory.CancelInventoryCount; 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.CancelInventoryCountCommand; 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; +import de.effigenix.infrastructure.inventory.web.dto.CancelInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest; @@ -39,6 +42,7 @@ public class InventoryCountController { private final StartInventoryCount startInventoryCount; private final RecordCountItem recordCountItem; private final CompleteInventoryCount completeInventoryCount; + private final CancelInventoryCount cancelInventoryCount; private final UserLookupPort userLookup; public InventoryCountController(CreateInventoryCount createInventoryCount, @@ -47,6 +51,7 @@ public class InventoryCountController { StartInventoryCount startInventoryCount, RecordCountItem recordCountItem, CompleteInventoryCount completeInventoryCount, + CancelInventoryCount cancelInventoryCount, UserLookupPort userLookup) { this.createInventoryCount = createInventoryCount; this.getInventoryCount = getInventoryCount; @@ -54,6 +59,7 @@ public class InventoryCountController { this.startInventoryCount = startInventoryCount; this.recordCountItem = recordCountItem; this.completeInventoryCount = completeInventoryCount; + this.cancelInventoryCount = cancelInventoryCount; this.userLookup = userLookup; } @@ -92,9 +98,10 @@ public class InventoryCountController { @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") public ResponseEntity> listInventoryCounts( @RequestParam(required = false) String storageLocationId, + @RequestParam(required = false) String status, Authentication authentication ) { - return switch (listInventoryCounts.execute(storageLocationId, ActorId.of(authentication.getName()))) { + return switch (listInventoryCounts.execute(storageLocationId, status, ActorId.of(authentication.getName()))) { case Result.Failure(var err) -> throw new InventoryCountDomainErrorException(err); case Result.Success(var counts) -> { var responses = counts.stream() @@ -145,6 +152,20 @@ public class InventoryCountController { }; } + @PostMapping("/{id}/cancel") + @PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')") + public ResponseEntity cancelInventoryCount( + @PathVariable String id, + @Valid @RequestBody CancelInventoryCountRequest request, + Authentication authentication + ) { + var cmd = new CancelInventoryCountCommand(id, request.reason()); + return switch (cancelInventoryCount.execute(cmd, ActorId.of(authentication.getName()))) { + case Result.Failure(var err) -> throw new InventoryCountDomainErrorException(err); + case Result.Success(var count) -> ResponseEntity.ok(InventoryCountResponse.from(count, userLookup)); + }; + } + // ==================== Exception Wrapper ==================== public static class InventoryCountDomainErrorException extends RuntimeException { diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CancelInventoryCountRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CancelInventoryCountRequest.java new file mode 100644 index 0000000..214850b --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/CancelInventoryCountRequest.java @@ -0,0 +1,5 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CancelInventoryCountRequest(@NotBlank String reason) {} 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 7157703..e7de128 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 @@ -14,6 +14,7 @@ public record InventoryCountResponse( String initiatedBy, String completedBy, String status, + String cancellationReason, Instant createdAt, List countItems ) { @@ -27,6 +28,7 @@ public record InventoryCountResponse( ? userLookup.resolveUsername(count.completedBy()).orElse(count.completedBy()) : null, count.status().name(), + count.cancellationReason(), count.createdAt(), count.countItems().stream() .map(CountItemResponse::from) 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 5794484..11f277b 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 @@ -72,6 +72,8 @@ public final class InventoryErrorHttpStatusMapper { case InventoryCountError.InvalidArticleId e -> 400; case InventoryCountError.InvalidQuantity e -> 400; case InventoryCountError.InvalidInventoryCountId e -> 400; + case InventoryCountError.CancellationReasonRequired e -> 400; + case InventoryCountError.InvalidStatus e -> 400; case InventoryCountError.Unauthorized e -> 403; case InventoryCountError.RepositoryFailure e -> 500; }; diff --git a/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java b/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java index a487d33..a6a13bb 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java +++ b/backend/src/main/java/de/effigenix/infrastructure/stub/StubInventoryCountRepository.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.stub; import de.effigenix.domain.inventory.InventoryCount; import de.effigenix.domain.inventory.InventoryCountId; import de.effigenix.domain.inventory.InventoryCountRepository; +import de.effigenix.domain.inventory.InventoryCountStatus; import de.effigenix.domain.inventory.StorageLocationId; import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.Result; @@ -34,6 +35,11 @@ public class StubInventoryCountRepository implements InventoryCountRepository { return Result.failure(STUB_ERROR); } + @Override + public Result> findByStatus(InventoryCountStatus status) { + return Result.failure(STUB_ERROR); + } + @Override public Result existsActiveByStorageLocationId(StorageLocationId storageLocationId) { return Result.failure(STUB_ERROR); diff --git a/backend/src/main/resources/db/changelog/changes/038-add-cancellation-reason-to-inventory-counts.xml b/backend/src/main/resources/db/changelog/changes/038-add-cancellation-reason-to-inventory-counts.xml new file mode 100644 index 0000000..6c5acda --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/038-add-cancellation-reason-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 03889c9..bc14b9b 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -43,5 +43,6 @@ + diff --git a/backend/src/test/java/de/effigenix/application/inventory/CancelInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/CancelInventoryCountTest.java new file mode 100644 index 0000000..70fa461 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/CancelInventoryCountTest.java @@ -0,0 +1,170 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.CancelInventoryCountCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.RepositoryError; +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 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.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Optional; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CancelInventoryCount Use Case") +class CancelInventoryCountTest { + + @Mock private InventoryCountRepository inventoryCountRepository; + @Mock private AuthorizationPort authPort; + @Mock private UnitOfWork unitOfWork; + + private CancelInventoryCount cancelInventoryCount; + private ActorId performedBy; + + @BeforeEach + void setUp() { + cancelInventoryCount = new CancelInventoryCount(inventoryCountRepository, authPort, unitOfWork); + performedBy = ActorId.of("admin-user"); + lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier) inv.getArgument(0)).get()); + } + + private CancelInventoryCountCommand validCommand() { + return new CancelInventoryCountCommand("count-1", "Nicht mehr benötigt"); + } + + private InventoryCount countWithStatus(InventoryCountStatus status) { + return InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", null, status, + null, Instant.now(), new ArrayList<>() + ); + } + + @Test + @DisplayName("should cancel OPEN inventory count") + void should_Cancel_OpenCount() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN)))); + when(inventoryCountRepository.save(any())).thenReturn(Result.success(null)); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED); + assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt"); + verify(inventoryCountRepository).save(any(InventoryCount.class)); + } + + @Test + @DisplayName("should cancel COUNTING inventory count") + void should_Cancel_CountingCount() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COUNTING)))); + when(inventoryCountRepository.save(any())).thenReturn(Result.success(null)); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED); + assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt"); + verify(inventoryCountRepository).save(any(InventoryCount.class)); + } + + @Test + @DisplayName("should fail when status is COMPLETED") + void should_Fail_When_Completed() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COMPLETED)))); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + verify(inventoryCountRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when status is already CANCELLED") + void should_Fail_When_AlreadyCancelled() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.CANCELLED)))); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + verify(inventoryCountRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when inventory count not found") + void should_Fail_When_NotFound() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class); + verify(inventoryCountRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when unauthorized") + void should_Fail_When_Unauthorized() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(false); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class); + verify(inventoryCountRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail when repository findById returns error") + void should_Fail_When_RepositoryError() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = cancelInventoryCount.execute(validCommand(), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); + } + + @Test + @DisplayName("should fail when reason is blank") + void should_Fail_When_ReasonBlank() { + when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true); + when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) + .thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN)))); + + var result = cancelInventoryCount.execute( + new CancelInventoryCountCommand("count-1", " "), performedBy); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class); + verify(inventoryCountRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java b/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java index fbe0164..406d295 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/CompleteInventoryCountTest.java @@ -237,7 +237,7 @@ class CompleteInventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) @@ -256,7 +256,7 @@ class CompleteInventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), new ArrayList<>() + null, Instant.now(), new ArrayList<>() ); when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) @@ -372,7 +372,7 @@ class CompleteInventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); } 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 ce5eec7..d33f12e 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/GetInventoryCountTest.java @@ -44,6 +44,7 @@ class GetInventoryCountTest { "user-1", null, InventoryCountStatus.OPEN, + null, 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 8a02b95..78a8d69 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/ListInventoryCountsTest.java @@ -44,6 +44,7 @@ class ListInventoryCountsTest { "user-1", null, InventoryCountStatus.OPEN, + null, Instant.now(), List.of() ); @@ -55,6 +56,7 @@ class ListInventoryCountsTest { "user-1", null, InventoryCountStatus.COMPLETED, + null, Instant.now(), List.of() ); @@ -65,7 +67,7 @@ class ListInventoryCountsTest { void shouldReturnAllCountsWhenNoFilter() { when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2))); - var result = listInventoryCounts.execute(null, actorId); + var result = listInventoryCounts.execute(null, null, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(2); @@ -78,7 +80,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.success(List.of(count1))); - var result = listInventoryCounts.execute("location-1", actorId); + var result = listInventoryCounts.execute("location-1", null, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).hasSize(1); @@ -92,7 +94,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findAll()) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = listInventoryCounts.execute(null, actorId); + var result = listInventoryCounts.execute(null, null, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -104,7 +106,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); - var result = listInventoryCounts.execute("location-1", actorId); + var result = listInventoryCounts.execute("location-1", null, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); @@ -116,7 +118,7 @@ class ListInventoryCountsTest { when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown"))) .thenReturn(Result.success(List.of())); - var result = listInventoryCounts.execute("unknown", actorId); + var result = listInventoryCounts.execute("unknown", null, actorId); assertThat(result.isSuccess()).isTrue(); assertThat(result.unsafeGetValue()).isEmpty(); @@ -125,9 +127,57 @@ class ListInventoryCountsTest { @Test @DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId") void shouldFailWhenBlankStorageLocationId() { - var result = listInventoryCounts.execute(" ", actorId); + var result = listInventoryCounts.execute(" ", null, actorId); assertThat(result.isFailure()).isTrue(); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); } + + @Test + @DisplayName("should filter by status only") + void shouldFilterByStatusOnly() { + when(inventoryCountRepository.findByStatus(InventoryCountStatus.OPEN)) + .thenReturn(Result.success(List.of(count1))); + + var result = listInventoryCounts.execute(null, "OPEN", actorId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + verify(inventoryCountRepository).findByStatus(InventoryCountStatus.OPEN); + verify(inventoryCountRepository, never()).findAll(); + } + + @Test + @DisplayName("should filter by storageLocationId and status") + void shouldFilterByStorageLocationIdAndStatus() { + when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) + .thenReturn(Result.success(List.of(count1, count2))); + + var result = listInventoryCounts.execute("location-1", "OPEN", actorId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.unsafeGetValue()).hasSize(1); + assertThat(result.unsafeGetValue().getFirst().status()).isEqualTo(InventoryCountStatus.OPEN); + } + + @Test + @DisplayName("should fail with InvalidStatus for invalid status string") + void shouldFailWhenInvalidStatus() { + var result = listInventoryCounts.execute(null, "INVALID", actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatus.class); + } + + @Test + @DisplayName("should fail when unauthorized") + void shouldFailWhenUnauthorized() { + reset(authPort); + when(authPort.can(any(ActorId.class), any())).thenReturn(false); + + var result = listInventoryCounts.execute(null, null, actorId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class); + } } 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 1637551..2f9b830 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/RecordCountItemTest.java @@ -56,7 +56,7 @@ class RecordCountItemTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), List.of( + null, Instant.now(), List.of( CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null), CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"), @@ -227,7 +227,7 @@ class RecordCountItemTest { var openCount = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), List.of( + null, 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 37e79d8..d21e04b 100644 --- a/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/application/inventory/StartInventoryCountTest.java @@ -59,7 +59,7 @@ class StartInventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), items + null, Instant.now(), items ); } @@ -111,7 +111,7 @@ class StartInventoryCountTest { var emptyCount = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), List.of() + null, Instant.now(), List.of() ); when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) @@ -129,7 +129,7 @@ class StartInventoryCountTest { var countingCount = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), List.of( + null, 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/InventoryCountReconciliationServiceTest.java b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java index fb040ff..adeb0c1 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountReconciliationServiceTest.java @@ -225,7 +225,7 @@ class InventoryCountReconciliationServiceTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, - Instant.now(), items + null, Instant.now(), items ); var stocks = List.of(stockFor("article-1", "stock-1")); @@ -241,7 +241,7 @@ class InventoryCountReconciliationServiceTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, - Instant.now(), new ArrayList<>(items) + null, Instant.now(), new ArrayList<>(items) ); } 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 4047a3f..d4e6b00 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/InventoryCountTest.java @@ -260,6 +260,7 @@ class InventoryCountTest { "user-1", null, InventoryCountStatus.COUNTING, + null, java.time.Instant.now(), java.util.List.of() ); @@ -383,7 +384,7 @@ class InventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, status, - Instant.now(), List.of() + null, Instant.now(), List.of() ); } } @@ -454,7 +455,7 @@ class InventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, status, - Instant.now(), items + null, Instant.now(), items ); } } @@ -527,7 +528,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED, - Instant.now(), items + null, Instant.now(), items ); var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); @@ -601,7 +602,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER); @@ -704,7 +705,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("c1"), StorageLocationId.of("l1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), List.of() + null, Instant.now(), List.of() ); assertThat(count.isActive()).isTrue(); } @@ -715,7 +716,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("c1"), StorageLocationId.of("l1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED, - Instant.now(), List.of() + null, Instant.now(), List.of() ); assertThat(count.isActive()).isFalse(); } @@ -726,7 +727,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("c1"), StorageLocationId.of("l1"), LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED, - Instant.now(), List.of() + null, Instant.now(), List.of() ); assertThat(count.isActive()).isFalse(); } @@ -755,7 +756,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); assertThat(count.id().value()).isEqualTo("count-1"); @@ -789,12 +790,12 @@ class InventoryCountTest { var a = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"), LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN, - Instant.now(), List.of() + null, Instant.now(), List.of() ); var b = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"), LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED, - Instant.now(), List.of() + null, Instant.now(), List.of() ); assertThat(a).isEqualTo(b); @@ -807,12 +808,12 @@ class InventoryCountTest { var a = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), List.of() + null, Instant.now(), List.of() ); var b = InventoryCount.reconstitute( InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, - Instant.now(), List.of() + null, Instant.now(), List.of() ); assertThat(a).isNotEqualTo(b); @@ -861,7 +862,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); var result = count.complete("user-2"); @@ -888,7 +889,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, - Instant.now(), createCountedItems() + null, Instant.now(), createCountedItems() ); var result = count.complete("user-3"); @@ -903,7 +904,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED, - Instant.now(), createCountedItems() + null, Instant.now(), createCountedItems() ); var result = count.complete("user-2"); @@ -940,7 +941,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), new ArrayList<>() + null, Instant.now(), new ArrayList<>() ); var result = count.complete("user-2"); @@ -963,7 +964,7 @@ class InventoryCountTest { var count = InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); var result = count.complete("user-2"); @@ -1010,7 +1011,7 @@ class InventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, Instant.now(), items ); } @@ -1023,6 +1024,100 @@ class InventoryCountTest { } } + // ==================== cancel ==================== + + @Nested + @DisplayName("cancel()") + class Cancel { + + @Test + @DisplayName("should cancel from OPEN status") + void shouldCancelFromOpen() { + var count = createOpenCount(); + + var result = count.cancel("Nicht mehr benötigt"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED); + assertThat(count.cancellationReason()).isEqualTo("Nicht mehr benötigt"); + } + + @Test + @DisplayName("should cancel from COUNTING status") + void shouldCancelFromCounting() { + var count = createCountingCount(); + + var result = count.cancel("Falsche Artikel"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED); + assertThat(count.cancellationReason()).isEqualTo("Falsche Artikel"); + } + + @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, + null, Instant.now(), new ArrayList<>() + ); + + var result = count.cancel("Zu spät"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when status is already CANCELLED") + void shouldFailWhenAlreadyCancelled() { + var count = InventoryCount.reconstitute( + InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), + LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED, + "Alter Grund", Instant.now(), new ArrayList<>() + ); + + var result = count.cancel("Neuer Grund"); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class); + } + + @Test + @DisplayName("should fail when reason is null") + void shouldFailWhenReasonNull() { + var count = createOpenCount(); + + var result = count.cancel(null); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class); + } + + @Test + @DisplayName("should fail when reason is blank") + void shouldFailWhenReasonBlank() { + var count = createOpenCount(); + + var result = count.cancel(" "); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class); + } + + @Test + @DisplayName("should not modify status when validation fails") + void shouldNotModifyStatusOnFailure() { + var count = createOpenCount(); + + count.cancel(null); + + assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN); + assertThat(count.cancellationReason()).isNull(); + } + } + // ==================== Helpers ==================== private InventoryCount createOpenCount() { @@ -1036,7 +1131,7 @@ class InventoryCountTest { return InventoryCount.reconstitute( InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, - Instant.now(), items + null, 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 6bb9c15..eb43da6 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 @@ -695,6 +695,158 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Inventur abbrechen (US-6.4) ==================== + + @Nested + @DisplayName("US-6.4 – Inventur abbrechen und abfragen") + class CancelAndFilter { + + @Test + @DisplayName("Inventur abbrechen → 200 mit Grund und Status CANCELLED") + void cancelInventoryCount_returns200() throws Exception { + String countId = createInventoryCountWithStock(); + + String body = """ + {"reason": "Falsche Artikel ausgewählt"} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CANCELLED")) + .andExpect(jsonPath("$.cancellationReason").value("Falsche Artikel ausgewählt")); + } + + @Test + @DisplayName("Inventur abbrechen ohne Grund → 400") + void cancelInventoryCount_noReason_returns400() throws Exception { + String countId = createInventoryCountWithStock(); + + String body = """ + {"reason": ""} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Abgeschlossene Inventur abbrechen → 409") + void cancelInventoryCount_completed_returns409() throws Exception { + String countId = createAndCompleteCount(); + + String body = """ + {"reason": "Zu spät bemerkt"} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION")); + } + + @Test + @DisplayName("Filter nach Status OPEN → nur offene Inventuren") + void listInventoryCounts_filterByStatus_returnsFiltered() throws Exception { + // Create two counts: one open, one will be cancelled + createInventoryCountWithStock(); + + // Need a second storage location for a second count + String storageLocationId2 = createStorageLocation(); + String articleId2 = createArticleId(); + createStockWithBatch(articleId2, storageLocationId2); + + var request2 = new CreateInventoryCountRequest(storageLocationId2, LocalDate.now().toString()); + var result2 = mockMvc.perform(post("/api/inventory/inventory-counts") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request2))) + .andExpect(status().isCreated()) + .andReturn(); + String countId2 = objectMapper.readTree(result2.getResponse().getContentAsString()).get("id").asText(); + + // Cancel one + String cancelBody = """ + {"reason": "Nicht benötigt"} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + countId2 + "/cancel") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(cancelBody)) + .andExpect(status().isOk()); + + // Filter by OPEN + mockMvc.perform(get("/api/inventory/inventory-counts?status=OPEN") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].status").value("OPEN")); + } + + @Test + @DisplayName("Filter nach ungültigem Status → 400") + void listInventoryCounts_invalidStatus_returns400() throws Exception { + mockMvc.perform(get("/api/inventory/inventory-counts?status=INVALID") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_STATUS")); + } + + @Test + @DisplayName("Inventur abbrechen ohne Token → 401") + void cancelInventoryCount_noToken_returns401() throws Exception { + String body = """ + {"reason": "Test"} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Inventur abbrechen ohne Berechtigung → 403") + void cancelInventoryCount_noPermission_returns403() throws Exception { + String body = """ + {"reason": "Test"} + """; + mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel") + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + } + + private String createAndCompleteCount() throws Exception { + String countId = createInventoryCountWithStock(); + + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()); + + String itemId = getFirstCountItemId(countId); + + String recordBody = """ + {"actualQuantityAmount": "25.0", "actualQuantityUnit": "KILOGRAM"} + """; + mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(recordBody)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete") + .header("Authorization", "Bearer " + completerToken)) + .andExpect(status().isOk()); + + return countId; + } + } + // ==================== Helpers ==================== private String createInventoryCountWithStock() throws Exception { diff --git a/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx b/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx index 28515e4..cb40f1a 100644 --- a/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx +++ b/frontend/apps/cli/src/components/inventory/InventoryCountDetailScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; import { useNavigation } from '../../state/navigation-context.js'; @@ -11,7 +11,7 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js'; import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client'; import type { InventoryCountStatus, CountItemDTO } from '@effigenix/api-client'; -type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete'; +type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete' | 'cancel-reason' | 'confirm-cancel'; const STATUS_COLORS: Record = { OPEN: 'yellow', @@ -33,6 +33,7 @@ export function InventoryCountDetailScreen() { startInventoryCount, recordCountItem, completeInventoryCount, + cancelInventoryCount, clearError, } = useInventoryCounts(); const { articleName, locationName } = useStockNameLookup(); @@ -42,6 +43,7 @@ export function InventoryCountDetailScreen() { const [itemIndex, setItemIndex] = useState(0); const [amount, setAmount] = useState(''); const [success, setSuccess] = useState(null); + const [cancelReason, setCancelReason] = useState(''); const countId = params.inventoryCountId ?? ''; @@ -61,12 +63,14 @@ export function InventoryCountDetailScreen() { const actions: { label: string; action: string }[] = []; if (inventoryCount.status === 'OPEN') { actions.push({ label: 'Zählung starten', action: 'start' }); + actions.push({ label: 'Inventur abbrechen', action: 'cancel' }); } if (inventoryCount.status === 'COUNTING') { actions.push({ label: 'Position erfassen', action: 'record' }); if (allCounted && isDifferentUser) { actions.push({ label: 'Inventur abschließen', action: 'complete' }); } + actions.push({ label: 'Inventur abbrechen', action: 'cancel' }); } return actions; }; @@ -108,6 +112,16 @@ export function InventoryCountDetailScreen() { } }; + const handleCancel = async () => { + if (!cancelReason.trim()) return; + const result = await cancelInventoryCount(countId, cancelReason.trim()); + if (result) { + setSuccess('Inventur abgebrochen.'); + setCancelReason(''); + setMode('view'); + } + }; + useInput((input, key) => { if (loading) return; @@ -116,6 +130,17 @@ export function InventoryCountDetailScreen() { return; } + if (mode === 'cancel-reason') { + if (key.escape) { setCancelReason(''); setMode('view'); } + return; + } + + if (mode === 'confirm-cancel') { + if (input.toLowerCase() === 'j') void handleCancel(); + if (input.toLowerCase() === 'n' || key.escape) setMode('view'); + return; + } + if (mode === 'confirm-start') { if (input.toLowerCase() === 'j') void handleStart(); if (input.toLowerCase() === 'n' || key.escape) setMode('view'); @@ -147,6 +172,7 @@ export function InventoryCountDetailScreen() { if (action === 'start') setMode('confirm-start'); else if (action === 'record') { setMode('record-item'); setItemIndex(0); } else if (action === 'complete') setMode('confirm-complete'); + else if (action === 'cancel') { setCancelReason(''); setMode('cancel-reason'); } } if (key.escape) setMode('view'); return; @@ -207,6 +233,9 @@ export function InventoryCountDetailScreen() { {inventoryCount.completedBy && ( Abgeschl. von: {inventoryCount.completedBy} )} + {inventoryCount.cancellationReason && ( + Abbruchgrund: {inventoryCount.cancellationReason} + )} Fortschritt: {progressBar} {countedCount}/{items.length} ({progressPct}%) @@ -311,6 +340,33 @@ export function InventoryCountDetailScreen() { )} + {/* Cancel Reason */} + {mode === 'cancel-reason' && ( + + Inventur abbrechen + Bitte Grund eingeben: + + + { if (cancelReason.trim()) setMode('confirm-cancel'); }} + focus={true} + /> + + Enter bestätigen · Escape abbrechen + + )} + + {/* Confirm Cancel */} + {mode === 'confirm-cancel' && ( + + Inventur wirklich abbrechen? + Grund: {cancelReason} + [J] Ja · [N] Nein + + )} + {/* Four-eyes hint */} {inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && ( diff --git a/frontend/apps/cli/src/hooks/useInventoryCounts.ts b/frontend/apps/cli/src/hooks/useInventoryCounts.ts index d1f8e2e..37bdbdf 100644 --- a/frontend/apps/cli/src/hooks/useInventoryCounts.ts +++ b/frontend/apps/cli/src/hooks/useInventoryCounts.ts @@ -94,6 +94,18 @@ export function useInventoryCounts() { } }, []); + const cancelInventoryCount = useCallback(async (id: string, reason: string) => { + setState((s) => ({ ...s, loading: true, error: null })); + try { + const inventoryCount = await client.inventoryCounts.cancel(id, { reason }); + setState((s) => ({ ...s, inventoryCount, loading: false, error: null })); + return inventoryCount; + } catch (err) { + setState((s) => ({ ...s, loading: false, error: errorMessage(err) })); + return null; + } + }, []); + const clearError = useCallback(() => { setState((s) => ({ ...s, error: null })); }, []); @@ -106,6 +118,7 @@ export function useInventoryCounts() { startInventoryCount, recordCountItem, completeInventoryCount, + cancelInventoryCount, clearError, }; } diff --git a/frontend/packages/api-client/src/resources/inventory-counts.ts b/frontend/packages/api-client/src/resources/inventory-counts.ts index 57d1059..66a1ea1 100644 --- a/frontend/packages/api-client/src/resources/inventory-counts.ts +++ b/frontend/packages/api-client/src/resources/inventory-counts.ts @@ -5,10 +5,11 @@ import type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, + CancelInventoryCountRequest, InventoryCountStatus, } from '@effigenix/types'; -export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, InventoryCountStatus }; +export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, CancelInventoryCountRequest, InventoryCountStatus }; export const INVENTORY_COUNT_STATUS_LABELS: Record = { OPEN: 'Offen', @@ -19,6 +20,7 @@ export const INVENTORY_COUNT_STATUS_LABELS: Record export interface InventoryCountFilter { storageLocationId?: string; + status?: string; } const BASE = '/api/inventory/inventory-counts'; @@ -28,6 +30,7 @@ export function createInventoryCountsResource(client: AxiosInstance) { async list(filter?: InventoryCountFilter): Promise { const params: Record = {}; if (filter?.storageLocationId) params.storageLocationId = filter.storageLocationId; + if (filter?.status) params.status = filter.status; const res = await client.get(BASE, { params }); return res.data; }, @@ -56,6 +59,11 @@ export function createInventoryCountsResource(client: AxiosInstance) { const res = await client.post(`${BASE}/${id}/complete`); return res.data; }, + + async cancel(id: string, request: CancelInventoryCountRequest): Promise { + const res = await client.post(`${BASE}/${id}/cancel`, request); + return res.data; + }, }; } diff --git a/frontend/packages/types/src/inventory-count.ts b/frontend/packages/types/src/inventory-count.ts index edd5d75..5cd9653 100644 --- a/frontend/packages/types/src/inventory-count.ts +++ b/frontend/packages/types/src/inventory-count.ts @@ -20,6 +20,7 @@ export interface InventoryCountDTO { countDate: string; initiatedBy: string; completedBy: string | null; + cancellationReason: string | null; status: InventoryCountStatus; createdAt: string; countItems: CountItemDTO[]; @@ -34,3 +35,7 @@ export interface RecordCountItemRequest { actualQuantityAmount: string; actualQuantityUnit: string; } + +export interface CancelInventoryCountRequest { + reason: string; +}