1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:19:35 +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,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<StockError, Void> 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);
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record RemoveStockBatchCommand(
String stockId,
String batchId,
String quantityAmount,
String quantityUnit
) {}

View file

@ -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<StockError, Void> 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; }

View file

@ -92,6 +92,42 @@ public class StockBatch {
return new StockBatch(id, batchReference, quantity, expiryDate, status, receivedAt);
}
// ==================== Removal ====================
public Result<StockError, Quantity> 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; }

View file

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

View file

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

View file

@ -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<Void> 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;

View file

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

View file

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

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