From e7c3258f0742328ef4bbe41d0321f40f99fab4ee Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Thu, 19 Feb 2026 23:46:34 +0100 Subject: [PATCH] feat(inventory): Charge sperren/entsperren (blockBatch/unblockBatch) (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gesperrte Chargen können nicht entnommen oder reserviert werden. blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; unblockBatch: BLOCKED → AVAILABLE/EXPIRING_SOON (MHD-Check). --- .../inventory/BlockStockBatch.java | 46 ++++ .../inventory/UnblockStockBatch.java | 46 ++++ .../command/BlockStockBatchCommand.java | 7 + .../command/UnblockStockBatchCommand.java | 6 + .../de/effigenix/domain/inventory/Stock.java | 48 ++++ .../domain/inventory/StockBatch.java | 4 + .../domain/inventory/StockError.java | 10 + .../config/InventoryUseCaseConfiguration.java | 12 + .../web/controller/StockController.java | 53 ++++- .../web/dto/BlockStockBatchRequest.java | 7 + .../InventoryErrorHttpStatusMapper.java | 2 + .../inventory/BlockStockBatchTest.java | 158 +++++++++++++ .../inventory/UnblockStockBatchTest.java | 158 +++++++++++++ .../effigenix/domain/inventory/StockTest.java | 160 +++++++++++++ .../web/StockControllerIntegrationTest.java | 218 ++++++++++++++++++ 15 files changed, 934 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/BlockStockBatch.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/UnblockStockBatch.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/BlockStockBatchCommand.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/UnblockStockBatchCommand.java create mode 100644 backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/BlockStockBatchRequest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/BlockStockBatch.java b/backend/src/main/java/de/effigenix/application/inventory/BlockStockBatch.java new file mode 100644 index 0000000..5e6126b --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/BlockStockBatch.java @@ -0,0 +1,46 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BlockStockBatchCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class BlockStockBatch { + + private final StockRepository stockRepository; + + public BlockStockBatch(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(BlockStockBatchCommand cmd) { + // 1. Stock laden + Stock stock; + switch (stockRepository.findById(StockId.of(cmd.stockId()))) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new StockError.StockNotFound(cmd.stockId())); + } + stock = opt.get(); + } + } + + // 2. Batch sperren (Domain validiert) + switch (stock.blockBatch(StockBatchId.of(cmd.batchId()))) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 3. Stock speichern + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(null); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/UnblockStockBatch.java b/backend/src/main/java/de/effigenix/application/inventory/UnblockStockBatch.java new file mode 100644 index 0000000..7d26770 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/UnblockStockBatch.java @@ -0,0 +1,46 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.UnblockStockBatchCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class UnblockStockBatch { + + private final StockRepository stockRepository; + + public UnblockStockBatch(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(UnblockStockBatchCommand cmd) { + // 1. Stock laden + Stock stock; + switch (stockRepository.findById(StockId.of(cmd.stockId()))) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var opt) -> { + if (opt.isEmpty()) { + return Result.failure(new StockError.StockNotFound(cmd.stockId())); + } + stock = opt.get(); + } + } + + // 2. Batch entsperren (Domain validiert) + switch (stock.unblockBatch(StockBatchId.of(cmd.batchId()))) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 3. Stock speichern + switch (stockRepository.save(stock)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.RepositoryFailure(err.message())); } + case Result.Success(var ignored) -> { } + } + + return Result.success(null); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/BlockStockBatchCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/BlockStockBatchCommand.java new file mode 100644 index 0000000..23db39a --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/BlockStockBatchCommand.java @@ -0,0 +1,7 @@ +package de.effigenix.application.inventory.command; + +public record BlockStockBatchCommand( + String stockId, + String batchId, + String reason +) {} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/UnblockStockBatchCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/UnblockStockBatchCommand.java new file mode 100644 index 0000000..e87fc85 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/UnblockStockBatchCommand.java @@ -0,0 +1,6 @@ +package de.effigenix.application.inventory.command; + +public record UnblockStockBatchCommand( + String stockId, + String batchId +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index 543657f..d2c83ad 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId; import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Result; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -17,6 +18,8 @@ import java.util.Objects; * - MinimumLevel optional (quantity amount >= 0) * - MinimumShelfLife optional (days > 0) * - BatchReference (batchId + batchType) unique within batches + * - blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; EXPIRED → not allowed; already BLOCKED → error + * - unblockBatch: BLOCKED → AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED → error */ public class Stock { @@ -150,6 +153,51 @@ public class Stock { return Result.success(null); } + public Result blockBatch(StockBatchId batchId) { + StockBatch batch = batches.stream() + .filter(b -> b.id().equals(batchId)) + .findFirst() + .orElse(null); + if (batch == null) { + return Result.failure(new StockError.BatchNotFound(batchId.value())); + } + if (batch.status() == StockBatchStatus.BLOCKED) { + return Result.failure(new StockError.BatchAlreadyBlocked(batchId.value())); + } + if (batch.status() == StockBatchStatus.EXPIRED) { + return Result.failure(new StockError.BatchNotAvailable(batchId.value(), batch.status().name())); + } + + int index = this.batches.indexOf(batch); + this.batches.set(index, batch.withStatus(StockBatchStatus.BLOCKED)); + return Result.success(null); + } + + public Result unblockBatch(StockBatchId batchId) { + StockBatch batch = batches.stream() + .filter(b -> b.id().equals(batchId)) + .findFirst() + .orElse(null); + if (batch == null) { + return Result.failure(new StockError.BatchNotFound(batchId.value())); + } + if (batch.status() != StockBatchStatus.BLOCKED) { + return Result.failure(new StockError.BatchNotBlocked(batchId.value())); + } + + StockBatchStatus newStatus = StockBatchStatus.AVAILABLE; + if (minimumShelfLife != null) { + LocalDate threshold = LocalDate.now().plusDays(minimumShelfLife.days()); + if (batch.expiryDate().isBefore(threshold)) { + newStatus = StockBatchStatus.EXPIRING_SOON; + } + } + + int index = this.batches.indexOf(batch); + this.batches.set(index, batch.withStatus(newStatus)); + return Result.success(null); + } + // ==================== Getters ==================== public StockId id() { return id; } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java b/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java index bf160fa..a8b510b 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java @@ -128,6 +128,10 @@ public class StockBatch { return reconstitute(this.id, this.batchReference, newQuantity, this.expiryDate, this.status, this.receivedAt); } + public StockBatch withStatus(StockBatchStatus newStatus) { + return reconstitute(this.id, this.batchReference, this.quantity, this.expiryDate, newStatus, this.receivedAt); + } + // ==================== Getters ==================== public StockBatchId id() { return id; } diff --git a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java index accb5df..f94d564 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java @@ -70,6 +70,16 @@ public sealed interface StockError { @Override public String message() { return "Batch " + id + " is not available for removal (status: " + status + ")"; } } + record BatchAlreadyBlocked(String id) implements StockError { + @Override public String code() { return "BATCH_ALREADY_BLOCKED"; } + @Override public String message() { return "Batch " + id + " is already blocked"; } + } + + record BatchNotBlocked(String id) implements StockError { + @Override public String code() { return "BATCH_NOT_BLOCKED"; } + @Override public String message() { return "Batch " + id + " is not blocked"; } + } + record Unauthorized(String message) implements StockError { @Override public String code() { return "UNAUTHORIZED"; } } 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 8df950e..09bf4d6 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -2,8 +2,10 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; +import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.RemoveStockBatch; +import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; @@ -59,4 +61,14 @@ public class InventoryUseCaseConfiguration { public RemoveStockBatch removeStockBatch(StockRepository stockRepository) { return new RemoveStockBatch(stockRepository); } + + @Bean + public BlockStockBatch blockStockBatch(StockRepository stockRepository) { + return new BlockStockBatch(stockRepository); + } + + @Bean + public UnblockStockBatch unblockStockBatch(StockRepository stockRepository) { + return new UnblockStockBatch(stockRepository); + } } diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index 297c2bc..6b8404b 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -1,13 +1,18 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.AddStockBatch; +import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.RemoveStockBatch; +import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.command.AddStockBatchCommand; +import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand; +import de.effigenix.application.inventory.command.UnblockStockBatchCommand; import de.effigenix.domain.inventory.StockError; import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; +import de.effigenix.infrastructure.inventory.web.dto.BlockStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse; @@ -34,11 +39,16 @@ public class StockController { private final CreateStock createStock; private final AddStockBatch addStockBatch; private final RemoveStockBatch removeStockBatch; + private final BlockStockBatch blockStockBatch; + private final UnblockStockBatch unblockStockBatch; - public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch) { + public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, + BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) { this.createStock = createStock; this.addStockBatch = addStockBatch; this.removeStockBatch = removeStockBatch; + this.blockStockBatch = blockStockBatch; + this.unblockStockBatch = unblockStockBatch; } @PostMapping @@ -115,6 +125,47 @@ public class StockController { return ResponseEntity.ok().build(); } + @PostMapping("/{stockId}/batches/{batchId}/block") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity blockBatch( + @PathVariable String stockId, + @PathVariable String batchId, + @Valid @RequestBody BlockStockBatchRequest request, + Authentication authentication + ) { + logger.info("Blocking batch {} of stock {} by actor: {}", batchId, stockId, authentication.getName()); + + var cmd = new BlockStockBatchCommand(stockId, batchId, request.reason()); + var result = blockStockBatch.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Batch {} of stock {} blocked", batchId, stockId); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{stockId}/batches/{batchId}/unblock") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity unblockBatch( + @PathVariable String stockId, + @PathVariable String batchId, + Authentication authentication + ) { + logger.info("Unblocking batch {} of stock {} by actor: {}", batchId, stockId, authentication.getName()); + + var cmd = new UnblockStockBatchCommand(stockId, batchId); + var result = unblockStockBatch.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Batch {} of stock {} unblocked", batchId, stockId); + return ResponseEntity.ok().build(); + } + public static class StockDomainErrorException extends RuntimeException { private final StockError error; diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/BlockStockBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/BlockStockBatchRequest.java new file mode 100644 index 0000000..50e54bb --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/BlockStockBatchRequest.java @@ -0,0 +1,7 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record BlockStockBatchRequest( + @NotBlank String reason +) {} 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 af92d40..2227e91 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 @@ -30,6 +30,8 @@ public final class InventoryErrorHttpStatusMapper { case StockError.DuplicateBatchReference e -> 409; case StockError.NegativeStockNotAllowed e -> 409; case StockError.BatchNotAvailable e -> 409; + case StockError.BatchAlreadyBlocked e -> 409; + case StockError.BatchNotBlocked e -> 409; case StockError.InvalidMinimumLevel e -> 400; case StockError.InvalidMinimumShelfLife e -> 400; case StockError.InvalidArticleId e -> 400; diff --git a/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java new file mode 100644 index 0000000..064a80f --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/BlockStockBatchTest.java @@ -0,0 +1,158 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.BlockStockBatchCommand; +import de.effigenix.domain.inventory.*; +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 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("BlockStockBatch Use Case") +class BlockStockBatchTest { + + @Mock private StockRepository stockRepository; + + private BlockStockBatch blockStockBatch; + private StockBatchId batchId; + private Stock existingStock; + private BlockStockBatchCommand validCommand; + + @BeforeEach + void setUp() { + blockStockBatch = new BlockStockBatch(stockRepository); + + batchId = StockBatchId.of("batch-1"); + var batch = StockBatch.reconstitute( + batchId, + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.of(2026, 12, 31), + StockBatchStatus.AVAILABLE, + Instant.now() + ); + + existingStock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(batch)) + ); + + validCommand = new BlockStockBatchCommand("stock-1", "batch-1", "Quality issue"); + } + + @Test + @DisplayName("should block batch successfully") + void shouldBlockBatchSuccessfully() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = blockStockBatch.execute(validCommand); + + assertThat(result.isSuccess()).isTrue(); + verify(stockRepository).save(existingStock); + assertThat(existingStock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED); + } + + @Test + @DisplayName("should fail with StockNotFound when stock does not exist") + void shouldFailWhenStockNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = blockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findById fails") + void shouldFailWhenFindByIdFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = blockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when save fails") + void shouldFailWhenSaveFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = blockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should propagate domain error for batch not found") + void shouldPropagateBatchNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new BlockStockBatchCommand("stock-1", "nonexistent", "Quality issue"); + var result = blockStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should propagate BatchAlreadyBlocked when batch is already blocked") + void shouldPropagateBatchAlreadyBlocked() { + var blockedBatch = StockBatch.reconstitute( + batchId, + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.of(2026, 12, 31), + StockBatchStatus.BLOCKED, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(blockedBatch)) + ); + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(stock))); + + var result = blockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchAlreadyBlocked.class); + verify(stockRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java new file mode 100644 index 0000000..ac242e7 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/UnblockStockBatchTest.java @@ -0,0 +1,158 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.UnblockStockBatchCommand; +import de.effigenix.domain.inventory.*; +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 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("UnblockStockBatch Use Case") +class UnblockStockBatchTest { + + @Mock private StockRepository stockRepository; + + private UnblockStockBatch unblockStockBatch; + private StockBatchId batchId; + private Stock existingStock; + private UnblockStockBatchCommand validCommand; + + @BeforeEach + void setUp() { + unblockStockBatch = new UnblockStockBatch(stockRepository); + + batchId = StockBatchId.of("batch-1"); + var batch = StockBatch.reconstitute( + batchId, + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.of(2026, 12, 31), + StockBatchStatus.BLOCKED, + Instant.now() + ); + + existingStock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(batch)) + ); + + validCommand = new UnblockStockBatchCommand("stock-1", "batch-1"); + } + + @Test + @DisplayName("should unblock batch successfully") + void shouldUnblockBatchSuccessfully() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = unblockStockBatch.execute(validCommand); + + assertThat(result.isSuccess()).isTrue(); + verify(stockRepository).save(existingStock); + assertThat(existingStock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should fail with StockNotFound when stock does not exist") + void shouldFailWhenStockNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.empty())); + + var result = unblockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when findById fails") + void shouldFailWhenFindByIdFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = unblockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with RepositoryFailure when save fails") + void shouldFailWhenSaveFails() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())) + .thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); + + var result = unblockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should propagate domain error for batch not found") + void shouldPropagateBatchNotFound() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var cmd = new UnblockStockBatchCommand("stock-1", "nonexistent"); + var result = unblockStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should propagate BatchNotBlocked when batch is not blocked") + void shouldPropagateBatchNotBlocked() { + var availableBatch = StockBatch.reconstitute( + batchId, + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.of(2026, 12, 31), + StockBatchStatus.AVAILABLE, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.of("stock-1"), + de.effigenix.domain.masterdata.ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(availableBatch)) + ); + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(stock))); + + var result = unblockStockBatch.execute(validCommand); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotBlocked.class); + verify(stockRepository, never()).save(any()); + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index e1cf193..eb7c052 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -435,6 +435,166 @@ class StockTest { } } + // ==================== blockBatch ==================== + + @Nested + @DisplayName("blockBatch()") + class BlockBatch { + + @Test + @DisplayName("should block AVAILABLE batch") + void shouldBlockAvailableBatch() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + + var result = stock.blockBatch(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED); + } + + @Test + @DisplayName("should block EXPIRING_SOON batch") + void shouldBlockExpiringSoonBatch() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRING_SOON); + var batchId = stock.batches().getFirst().id(); + + var result = stock.blockBatch(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.BLOCKED); + } + + @Test + @DisplayName("should fail when batch is already BLOCKED") + void shouldFailWhenAlreadyBlocked() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED); + var batchId = stock.batches().getFirst().id(); + + var result = stock.blockBatch(batchId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchAlreadyBlocked.class); + } + + @Test + @DisplayName("should fail when batch is EXPIRED") + void shouldFailWhenExpired() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRED); + var batchId = stock.batches().getFirst().id(); + + var result = stock.blockBatch(batchId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class); + } + + @Test + @DisplayName("should fail when batch not found") + void shouldFailWhenBatchNotFound() { + var stock = createValidStock(); + + var result = stock.blockBatch(StockBatchId.of("nonexistent")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + } + } + + // ==================== unblockBatch ==================== + + @Nested + @DisplayName("unblockBatch()") + class UnblockBatch { + + @Test + @DisplayName("should unblock to AVAILABLE when no minimum shelf life") + void shouldUnblockToAvailable() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED); + var batchId = stock.batches().getFirst().id(); + + var result = stock.unblockBatch(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should unblock to EXPIRING_SOON when MHD check triggers") + void shouldUnblockToExpiringSoon() { + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.now().plusDays(5), + StockBatchStatus.BLOCKED, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), + ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), + new ArrayList<>(List.of(batch)) + ); + var batchId = stock.batches().getFirst().id(); + + var result = stock.unblockBatch(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.EXPIRING_SOON); + } + + @Test + @DisplayName("should unblock to AVAILABLE when MHD check passes") + void shouldUnblockToAvailableWhenMhdPasses() { + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("10"), UnitOfMeasure.KILOGRAM, null, null), + LocalDate.now().plusDays(60), + StockBatchStatus.BLOCKED, + Instant.now() + ); + var stock = Stock.reconstitute( + StockId.generate(), + ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, new MinimumShelfLife(30), + new ArrayList<>(List.of(batch)) + ); + var batchId = stock.batches().getFirst().id(); + + var result = stock.unblockBatch(batchId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches().getFirst().status()).isEqualTo(StockBatchStatus.AVAILABLE); + } + + @Test + @DisplayName("should fail when batch is not BLOCKED") + void shouldFailWhenNotBlocked() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + + var result = stock.unblockBatch(batchId); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotBlocked.class); + } + + @Test + @DisplayName("should fail when batch not found") + void shouldFailWhenBatchNotFound() { + var stock = createValidStock(); + + var result = stock.unblockBatch(StockBatchId.of("nonexistent")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + } + } + // ==================== Helpers ==================== private Stock createValidStock() { diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index 6ba1225..ca29240 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -345,6 +345,209 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Charge sperren (blockBatch) ==================== + + @Nested + @DisplayName("POST /{stockId}/batches/{batchId}/block – Charge sperren") + class BlockBatch { + + @Test + @DisplayName("Charge sperren → 200") + void blockBatch_returns200() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue detected"} + """)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("Bereits gesperrte Charge erneut sperren → 409") + void blockBatch_alreadyBlocked_returns409() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue detected"} + """)) + .andExpect(status().isOk()); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Another reason"} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_ALREADY_BLOCKED")); + } + + @Test + @DisplayName("Charge sperren – Batch nicht gefunden → 404") + void blockBatch_batchNotFound_returns404() throws Exception { + String stockId = createStock(); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND")); + } + + @Test + @DisplayName("Charge sperren – Stock nicht gefunden → 404") + void blockBatch_stockNotFound_returns404() throws Exception { + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", + UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND")); + } + + @Test + @DisplayName("Charge sperren ohne reason → 400") + void blockBatch_withoutReason_returns400() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": ""} + """)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Charge sperren ohne STOCK_WRITE → 403") + void blockBatch_withViewerToken_returns403() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Charge sperren ohne Token → 401") + void blockBatch_withoutToken_returns401() throws Exception { + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", + UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isUnauthorized()); + } + } + + // ==================== Charge entsperren (unblockBatch) ==================== + + @Nested + @DisplayName("POST /{stockId}/batches/{batchId}/unblock – Charge entsperren") + class UnblockBatch { + + @Test + @DisplayName("Gesperrte Charge entsperren → 200") + void unblockBatch_returns200() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + // Erst sperren + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/block", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"reason": "Quality issue"} + """)) + .andExpect(status().isOk()); + + // Dann entsperren + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("Nicht gesperrte Charge entsperren → 409") + void unblockBatch_notBlocked_returns409() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_BLOCKED")); + } + + @Test + @DisplayName("Charge entsperren – Batch nicht gefunden → 404") + void unblockBatch_batchNotFound_returns404() throws Exception { + String stockId = createStock(); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BATCH_NOT_FOUND")); + } + + @Test + @DisplayName("Charge entsperren – Stock nicht gefunden → 404") + void unblockBatch_stockNotFound_returns404() throws Exception { + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", + UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND")); + } + + @Test + @DisplayName("Charge entsperren ohne STOCK_WRITE → 403") + void unblockBatch_withViewerToken_returns403() throws Exception { + String stockId = createStock(); + String batchId = addBatchToStock(stockId); + + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", stockId, batchId) + .header("Authorization", "Bearer " + viewerToken) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Charge entsperren ohne Token → 401") + void unblockBatch_withoutToken_returns401() throws Exception { + mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches/{batchId}/unblock", + UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + // ==================== Hilfsmethoden ==================== private String createStorageLocation() throws Exception { @@ -362,6 +565,21 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); } + private String addBatchToStock(String stockId) throws Exception { + var request = new AddStockBatchRequest( + "BATCH-" + UUID.randomUUID().toString().substring(0, 8), + "PRODUCED", "10", "KILOGRAM", "2026-12-31"); + + var result = mockMvc.perform(post("/api/inventory/stocks/{stockId}/batches", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText(); + } + private String createStock() throws Exception { var request = new CreateStockRequest( UUID.randomUUID().toString(), storageLocationId, null, null, null);