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:
parent
ec736cf294
commit
05147227d1
11 changed files with 488 additions and 1 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue