1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:59:36 +01:00

feat(inventory): Charge entnehmen (removeBatch) (#6)

This commit is contained in:
Sebastian Frick 2026-02-19 22:53:54 +01:00
parent ec736cf294
commit 05147227d1
11 changed files with 488 additions and 1 deletions

View file

@ -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());
}
}

View file

@ -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))
);
}
}