diff --git a/backend/src/main/java/de/effigenix/application/inventory/RemoveStockBatch.java b/backend/src/main/java/de/effigenix/application/inventory/RemoveStockBatch.java new file mode 100644 index 0000000..a1e8d4d --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/RemoveStockBatch.java @@ -0,0 +1,70 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RemoveStockBatchCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Quantity; +import de.effigenix.shared.common.Result; +import de.effigenix.shared.common.UnitOfMeasure; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Transactional +public class RemoveStockBatch { + + private final StockRepository stockRepository; + + public RemoveStockBatch(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(RemoveStockBatchCommand 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. Quantity aus Command-Strings bauen + Quantity quantity; + try { + BigDecimal amount = new BigDecimal(cmd.quantityAmount()); + UnitOfMeasure uom; + try { + uom = UnitOfMeasure.valueOf(cmd.quantityUnit()); + } catch (IllegalArgumentException | NullPointerException e) { + return Result.failure(new StockError.InvalidQuantity("Invalid unit: " + cmd.quantityUnit())); + } + switch (Quantity.of(amount, uom)) { + case Result.Failure(var err) -> + { return Result.failure(new StockError.InvalidQuantity(err.message())); } + case Result.Success(var val) -> quantity = val; + } + } catch (NumberFormatException | NullPointerException e) { + return Result.failure(new StockError.InvalidQuantity( + "Invalid quantity amount: " + cmd.quantityAmount())); + } + + // 3. Entnahme durchführen (Domain validiert) + switch (stock.removeBatch(StockBatchId.of(cmd.batchId()), quantity)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 4. 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/RemoveStockBatchCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/RemoveStockBatchCommand.java new file mode 100644 index 0000000..f0d9844 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/RemoveStockBatchCommand.java @@ -0,0 +1,8 @@ +package de.effigenix.application.inventory.command; + +public record RemoveStockBatchCommand( + String stockId, + String batchId, + String quantityAmount, + String quantityUnit +) {} 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 cb088fc..543657f 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -1,6 +1,7 @@ package de.effigenix.domain.inventory; import de.effigenix.domain.masterdata.ArticleId; +import de.effigenix.shared.common.Quantity; import de.effigenix.shared.common.Result; import java.util.ArrayList; @@ -124,6 +125,31 @@ public class Stock { return Result.success(batch); } + public Result removeBatch(StockBatchId batchId, Quantity quantity) { + StockBatch batch = batches.stream() + .filter(b -> b.id().equals(batchId)) + .findFirst() + .orElse(null); + if (batch == null) { + return Result.failure(new StockError.BatchNotFound(batchId.value())); + } + + Quantity remaining; + switch (batch.removeQuantity(quantity)) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var val) -> remaining = val; + } + + if (remaining.amount().signum() == 0) { + this.batches.remove(batch); + } else { + int index = this.batches.indexOf(batch); + this.batches.set(index, batch.withQuantity(remaining)); + } + + 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 9abb522..bf160fa 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockBatch.java @@ -92,6 +92,42 @@ public class StockBatch { return new StockBatch(id, batchReference, quantity, expiryDate, status, receivedAt); } + // ==================== Removal ==================== + + public Result removeQuantity(Quantity toRemove) { + if (!isRemovable()) { + return Result.failure(new StockError.BatchNotAvailable(id.value(), status.name())); + } + if (this.quantity.uom() != toRemove.uom()) { + return Result.failure(new StockError.InvalidQuantity( + "Unit mismatch: batch has " + this.quantity.uom().symbol() + + ", removal requested " + toRemove.uom().symbol())); + } + BigDecimal remaining = this.quantity.amount().subtract(toRemove.amount()); + if (remaining.compareTo(BigDecimal.ZERO) < 0) { + return Result.failure(new StockError.NegativeStockNotAllowed()); + } + if (remaining.compareTo(BigDecimal.ZERO) == 0) { + return Result.success(Quantity.reconstitute(BigDecimal.ZERO, this.quantity.uom(), null, null)); + } + switch (Quantity.of(remaining, this.quantity.uom())) { + case Result.Failure(var err) -> { + return Result.failure(new StockError.InvalidQuantity(err.message())); + } + case Result.Success(var val) -> { + return Result.success(val); + } + } + } + + public boolean isRemovable() { + return status == StockBatchStatus.AVAILABLE || status == StockBatchStatus.EXPIRING_SOON; + } + + public StockBatch withQuantity(Quantity newQuantity) { + return reconstitute(this.id, this.batchReference, newQuantity, this.expiryDate, this.status, 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 d1e69d5..accb5df 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/StockError.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/StockError.java @@ -60,6 +60,16 @@ public sealed interface StockError { @Override public String message() { return "Batch not found: " + id; } } + record NegativeStockNotAllowed() implements StockError { + @Override public String code() { return "NEGATIVE_STOCK_NOT_ALLOWED"; } + @Override public String message() { return "Removal would result in negative stock"; } + } + + record BatchNotAvailable(String id, String status) implements StockError { + @Override public String code() { return "BATCH_NOT_AVAILABLE"; } + @Override public String message() { return "Batch " + id + " is not available for removal (status: " + status + ")"; } + } + 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 17f2cfd..8df950e 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.CreateStock; +import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.DeactivateStorageLocation; import de.effigenix.application.inventory.ListStorageLocations; @@ -53,4 +54,9 @@ public class InventoryUseCaseConfiguration { public AddStockBatch addStockBatch(StockRepository stockRepository) { return new AddStockBatch(stockRepository); } + + @Bean + public RemoveStockBatch removeStockBatch(StockRepository stockRepository) { + return new RemoveStockBatch(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 a9e0955..297c2bc 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 @@ -2,11 +2,14 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.CreateStock; +import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.CreateStockCommand; +import de.effigenix.application.inventory.command.RemoveStockBatchCommand; import de.effigenix.domain.inventory.StockError; import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest; +import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest; import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse; import de.effigenix.infrastructure.inventory.web.dto.StockResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -30,10 +33,12 @@ public class StockController { private final CreateStock createStock; private final AddStockBatch addStockBatch; + private final RemoveStockBatch removeStockBatch; - public StockController(CreateStock createStock, AddStockBatch addStockBatch) { + public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch) { this.createStock = createStock; this.addStockBatch = addStockBatch; + this.removeStockBatch = removeStockBatch; } @PostMapping @@ -86,6 +91,30 @@ public class StockController { .body(StockBatchResponse.from(result.unsafeGetValue())); } + @PostMapping("/{stockId}/batches/{batchId}/remove") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity removeBatch( + @PathVariable String stockId, + @PathVariable String batchId, + @Valid @RequestBody RemoveStockBatchRequest request, + Authentication authentication + ) { + logger.info("Removing from batch {} of stock {} by actor: {}", batchId, stockId, authentication.getName()); + + var cmd = new RemoveStockBatchCommand( + stockId, batchId, + request.quantityAmount(), request.quantityUnit() + ); + var result = removeStockBatch.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Batch removal completed for batch {} of stock {}", 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/RemoveStockBatchRequest.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RemoveStockBatchRequest.java new file mode 100644 index 0000000..fd32c6d --- /dev/null +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/dto/RemoveStockBatchRequest.java @@ -0,0 +1,8 @@ +package de.effigenix.infrastructure.inventory.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RemoveStockBatchRequest( + @NotBlank String quantityAmount, + @NotBlank String quantityUnit +) {} 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 ad612e7..af92d40 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 @@ -28,6 +28,8 @@ public final class InventoryErrorHttpStatusMapper { case StockError.BatchNotFound e -> 404; case StockError.DuplicateStock e -> 409; case StockError.DuplicateBatchReference e -> 409; + case StockError.NegativeStockNotAllowed e -> 409; + case StockError.BatchNotAvailable 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/RemoveStockBatchTest.java b/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java new file mode 100644 index 0000000..752960a --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/RemoveStockBatchTest.java @@ -0,0 +1,157 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.RemoveStockBatchCommand; +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("RemoveStockBatch Use Case") +class RemoveStockBatchTest { + + @Mock private StockRepository stockRepository; + + private RemoveStockBatch removeStockBatch; + private StockBatchId batchId; + private Stock existingStock; + private RemoveStockBatchCommand validCommand; + + @BeforeEach + void setUp() { + removeStockBatch = new RemoveStockBatch(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 RemoveStockBatchCommand("stock-1", "batch-1", "5", "KILOGRAM"); + } + + @Test + @DisplayName("should remove quantity from batch successfully") + void shouldRemoveQuantitySuccessfully() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = removeStockBatch.execute(validCommand); + + assertThat(result.isSuccess()).isTrue(); + verify(stockRepository).save(existingStock); + } + + @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 = removeStockBatch.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 = removeStockBatch.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 = removeStockBatch.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 RemoveStockBatchCommand("stock-1", "nonexistent", "5", "KILOGRAM"); + var result = removeStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidQuantity for invalid amount") + void shouldFailForInvalidAmount() { + var cmd = new RemoveStockBatchCommand("stock-1", "batch-1", "not-a-number", "KILOGRAM"); + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var result = removeStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with InvalidQuantity for invalid unit") + void shouldFailForInvalidUnit() { + var cmd = new RemoveStockBatchCommand("stock-1", "batch-1", "5", "INVALID"); + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var result = removeStockBatch.execute(cmd); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.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 0750e1d..e1cf193 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -318,10 +321,142 @@ class StockTest { } } + // ==================== removeBatch ==================== + + @Nested + @DisplayName("removeBatch()") + class RemoveBatch { + + @Test + @DisplayName("should reduce quantity on partial removal") + void shouldReduceQuantityOnPartialRemoval() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("3"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches()).hasSize(1); + assertThat(stock.batches().getFirst().quantity().amount()) + .isEqualByComparingTo(new BigDecimal("7")); + } + + @Test + @DisplayName("should remove batch when full quantity is removed") + void shouldRemoveBatchOnFullRemoval() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("10"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches()).isEmpty(); + } + + @Test + @DisplayName("should fail when batch is BLOCKED") + void shouldFailWhenBatchBlocked() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.BLOCKED); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class); + } + + @Test + @DisplayName("should fail when batch is EXPIRED") + void shouldFailWhenBatchExpired() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRED); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotAvailable.class); + } + + @Test + @DisplayName("should allow removal from EXPIRING_SOON batch") + void shouldAllowRemovalFromExpiringSoon() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.EXPIRING_SOON); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.batches()).hasSize(1); + assertThat(stock.batches().getFirst().quantity().amount()) + .isEqualByComparingTo(new BigDecimal("5")); + } + + @Test + @DisplayName("should fail when removal exceeds available quantity") + void shouldFailWhenRemovalExceedsQuantity() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("15"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.NegativeStockNotAllowed.class); + } + + @Test + @DisplayName("should fail when batch not found") + void shouldFailWhenBatchNotFound() { + var stock = createValidStock(); + var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.KILOGRAM).unsafeGetValue(); + + var result = stock.removeBatch(StockBatchId.of("nonexistent"), removeQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.BatchNotFound.class); + } + + @Test + @DisplayName("should fail when unit of measure does not match") + void shouldFailWhenUomMismatch() { + var stock = createStockWithBatch("10", UnitOfMeasure.KILOGRAM, StockBatchStatus.AVAILABLE); + var batchId = stock.batches().getFirst().id(); + var removeQty = Quantity.of(new BigDecimal("5"), UnitOfMeasure.LITER).unsafeGetValue(); + + var result = stock.removeBatch(batchId, removeQty); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.InvalidQuantity.class); + } + } + // ==================== Helpers ==================== private Stock createValidStock() { var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30); return Stock.create(draft).unsafeGetValue(); } + + private Stock createStockWithBatch(String amount, UnitOfMeasure uom, StockBatchStatus status) { + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal(amount), uom, null, null), + LocalDate.of(2026, 12, 31), + status, + Instant.now() + ); + return Stock.reconstitute( + StockId.generate(), + ArticleId.of("article-1"), + StorageLocationId.of("location-1"), + null, null, + new ArrayList<>(List.of(batch)) + ); + } }