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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.effigenix.application.inventory.command;
|
||||||
|
|
||||||
|
public record RemoveStockBatchCommand(
|
||||||
|
String stockId,
|
||||||
|
String batchId,
|
||||||
|
String quantityAmount,
|
||||||
|
String quantityUnit
|
||||||
|
) {}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.effigenix.domain.inventory;
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
import de.effigenix.domain.masterdata.ArticleId;
|
import de.effigenix.domain.masterdata.ArticleId;
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -124,6 +125,31 @@ public class Stock {
|
||||||
return Result.success(batch);
|
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 ====================
|
// ==================== Getters ====================
|
||||||
|
|
||||||
public StockId id() { return id; }
|
public StockId id() { return id; }
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,42 @@ public class StockBatch {
|
||||||
return new StockBatch(id, batchReference, quantity, expiryDate, status, receivedAt);
|
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 ====================
|
// ==================== Getters ====================
|
||||||
|
|
||||||
public StockBatchId id() { return id; }
|
public StockBatchId id() { return id; }
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,16 @@ public sealed interface StockError {
|
||||||
@Override public String message() { return "Batch not found: " + id; }
|
@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 {
|
record Unauthorized(String message) implements StockError {
|
||||||
@Override public String code() { return "UNAUTHORIZED"; }
|
@Override public String code() { return "UNAUTHORIZED"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config;
|
||||||
import de.effigenix.application.inventory.ActivateStorageLocation;
|
import de.effigenix.application.inventory.ActivateStorageLocation;
|
||||||
import de.effigenix.application.inventory.AddStockBatch;
|
import de.effigenix.application.inventory.AddStockBatch;
|
||||||
import de.effigenix.application.inventory.CreateStock;
|
import de.effigenix.application.inventory.CreateStock;
|
||||||
|
import de.effigenix.application.inventory.RemoveStockBatch;
|
||||||
import de.effigenix.application.inventory.CreateStorageLocation;
|
import de.effigenix.application.inventory.CreateStorageLocation;
|
||||||
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
import de.effigenix.application.inventory.DeactivateStorageLocation;
|
||||||
import de.effigenix.application.inventory.ListStorageLocations;
|
import de.effigenix.application.inventory.ListStorageLocations;
|
||||||
|
|
@ -53,4 +54,9 @@ public class InventoryUseCaseConfiguration {
|
||||||
public AddStockBatch addStockBatch(StockRepository stockRepository) {
|
public AddStockBatch addStockBatch(StockRepository stockRepository) {
|
||||||
return new AddStockBatch(stockRepository);
|
return new AddStockBatch(stockRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RemoveStockBatch removeStockBatch(StockRepository stockRepository) {
|
||||||
|
return new RemoveStockBatch(stockRepository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ package de.effigenix.infrastructure.inventory.web.controller;
|
||||||
|
|
||||||
import de.effigenix.application.inventory.AddStockBatch;
|
import de.effigenix.application.inventory.AddStockBatch;
|
||||||
import de.effigenix.application.inventory.CreateStock;
|
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.AddStockBatchCommand;
|
||||||
import de.effigenix.application.inventory.command.CreateStockCommand;
|
import de.effigenix.application.inventory.command.CreateStockCommand;
|
||||||
|
import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
|
||||||
import de.effigenix.domain.inventory.StockError;
|
import de.effigenix.domain.inventory.StockError;
|
||||||
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
|
||||||
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
|
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.StockBatchResponse;
|
||||||
import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
|
import de.effigenix.infrastructure.inventory.web.dto.StockResponse;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
|
@ -30,10 +33,12 @@ public class StockController {
|
||||||
|
|
||||||
private final CreateStock createStock;
|
private final CreateStock createStock;
|
||||||
private final AddStockBatch addStockBatch;
|
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.createStock = createStock;
|
||||||
this.addStockBatch = addStockBatch;
|
this.addStockBatch = addStockBatch;
|
||||||
|
this.removeStockBatch = removeStockBatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -86,6 +91,30 @@ public class StockController {
|
||||||
.body(StockBatchResponse.from(result.unsafeGetValue()));
|
.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 {
|
public static class StockDomainErrorException extends RuntimeException {
|
||||||
private final StockError error;
|
private final StockError error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -28,6 +28,8 @@ public final class InventoryErrorHttpStatusMapper {
|
||||||
case StockError.BatchNotFound e -> 404;
|
case StockError.BatchNotFound e -> 404;
|
||||||
case StockError.DuplicateStock e -> 409;
|
case StockError.DuplicateStock e -> 409;
|
||||||
case StockError.DuplicateBatchReference e -> 409;
|
case StockError.DuplicateBatchReference e -> 409;
|
||||||
|
case StockError.NegativeStockNotAllowed e -> 409;
|
||||||
|
case StockError.BatchNotAvailable e -> 409;
|
||||||
case StockError.InvalidMinimumLevel e -> 400;
|
case StockError.InvalidMinimumLevel e -> 400;
|
||||||
case StockError.InvalidMinimumShelfLife e -> 400;
|
case StockError.InvalidMinimumShelfLife e -> 400;
|
||||||
case StockError.InvalidArticleId e -> 400;
|
case StockError.InvalidArticleId e -> 400;
|
||||||
|
|
|
||||||
|
|
@ -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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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 ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
private Stock createValidStock() {
|
private Stock createValidStock() {
|
||||||
var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30);
|
var draft = new StockDraft("article-1", "location-1", "10", "KILOGRAM", 30);
|
||||||
return Stock.create(draft).unsafeGetValue();
|
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