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

feat(inventory): Reservierung bestätigen – Material entnehmen (US-4.3)

Allokierte Mengen werden physisch aus den Chargen abgezogen,
StockMovements erzeugt und die Reservation entfernt.
MovementType wird aus ReferenceType abgeleitet
(PRODUCTION_ORDER→PRODUCTION_CONSUMPTION, SALE_ORDER→SALE).
This commit is contained in:
Sebastian Frick 2026-02-25 22:56:45 +01:00
parent 19f1cf16a1
commit 0b6028b967
11 changed files with 773 additions and 2 deletions

View file

@ -0,0 +1,93 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.ConfirmReservationCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork;
import java.util.ArrayList;
import java.util.List;
public class ConfirmReservation {
private final StockRepository stockRepository;
private final StockMovementRepository stockMovementRepository;
private final UnitOfWork unitOfWork;
public ConfirmReservation(StockRepository stockRepository,
StockMovementRepository stockMovementRepository,
UnitOfWork unitOfWork) {
this.stockRepository = stockRepository;
this.stockMovementRepository = stockMovementRepository;
this.unitOfWork = unitOfWork;
}
public Result<StockError, Void> execute(ConfirmReservationCommand 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. Reservation bestätigen
ConfirmedReservation confirmed;
switch (stock.confirmReservation(ReservationId.of(cmd.reservationId()))) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> confirmed = val;
}
// 3. MovementType aus ReferenceType ableiten
String movementType = switch (confirmed.referenceType()) {
case PRODUCTION_ORDER -> "PRODUCTION_CONSUMPTION";
case SALE_ORDER -> "SALE";
};
// 4. StockMovements erzeugen
List<StockMovement> movements = new ArrayList<>();
for (ConfirmedAllocation alloc : confirmed.allocations()) {
var draft = new StockMovementDraft(
stock.id().value(),
stock.articleId().value(),
alloc.stockBatchId().value(),
alloc.batchReference().batchId(),
alloc.batchReference().batchType().name(),
movementType,
null,
alloc.allocatedQuantity().amount().toPlainString(),
alloc.allocatedQuantity().uom().name(),
null,
null,
cmd.performedBy()
);
switch (StockMovement.record(draft)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var val) -> movements.add(val);
}
}
// 5. Atomar speichern
return unitOfWork.executeAtomically(() -> {
switch (stockRepository.save(stock)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
for (StockMovement movement : movements) {
switch (stockMovementRepository.save(movement)) {
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,3 @@
package de.effigenix.application.inventory.command;
public record ConfirmReservationCommand(String stockId, String reservationId, String performedBy) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Quantity;
public record ConfirmedAllocation(
StockBatchId stockBatchId,
BatchReference batchReference,
Quantity allocatedQuantity
) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
import java.util.List;
public record ConfirmedReservation(
ReferenceType referenceType,
String referenceId,
List<ConfirmedAllocation> allocations
) {}

View file

@ -34,6 +34,8 @@ import java.util.stream.Collectors;
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount * - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
* - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC * - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
* - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities * - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities
* - confirmReservation: deducts allocated quantities from batches, removes reservation, returns ConfirmedReservation
* - batches with quantity 0 after deduction are removed
* - reservations track allocated quantities per batch; no over-reservation possible * - reservations track allocated quantities per batch; no over-reservation possible
*/ */
public class Stock { public class Stock {
@ -354,6 +356,61 @@ public class Stock {
return Result.success(reservation); return Result.success(reservation);
} }
public Result<StockError, ConfirmedReservation> confirmReservation(ReservationId reservationId) {
// 1. Reservation finden
Reservation reservation = reservations.stream()
.filter(r -> r.id().equals(reservationId))
.findFirst()
.orElse(null);
if (reservation == null) {
return Result.failure(new StockError.ReservationNotFound(reservationId.value()));
}
// 2. Für jede Allocation: Batch finden und BatchReference merken
List<ConfirmedAllocation> confirmedAllocations = new ArrayList<>();
for (StockBatchAllocation alloc : reservation.allocations()) {
StockBatch batch = batches.stream()
.filter(b -> b.id().equals(alloc.stockBatchId()))
.findFirst()
.orElse(null);
if (batch == null) {
return Result.failure(new StockError.BatchNotFound(alloc.stockBatchId().value()));
}
confirmedAllocations.add(new ConfirmedAllocation(
batch.id(), batch.batchReference(), alloc.allocatedQuantity()));
}
// 3. Für jede Allocation: Menge abziehen, bei 0 Batch entfernen
for (ConfirmedAllocation confirmed : confirmedAllocations) {
StockBatch batch = batches.stream()
.filter(b -> b.id().equals(confirmed.stockBatchId()))
.findFirst()
.orElse(null);
if (batch == null) {
return Result.failure(new StockError.BatchNotFound(confirmed.stockBatchId().value()));
}
Quantity remaining;
switch (batch.removeQuantity(confirmed.allocatedQuantity())) {
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));
}
}
// 4. Reservation entfernen
this.reservations.remove(reservation);
return Result.success(new ConfirmedReservation(
reservation.referenceType(), reservation.referenceId(), confirmedAllocations));
}
public Result<StockError, Void> releaseReservation(ReservationId reservationId) { public Result<StockError, Void> releaseReservation(ReservationId reservationId) {
Reservation reservation = reservations.stream() Reservation reservation = reservations.stream()
.filter(r -> r.id().equals(reservationId)) .filter(r -> r.id().equals(reservationId))

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.GetStockMovement; import de.effigenix.application.inventory.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements; import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.ConfirmReservation;
import de.effigenix.application.inventory.RecordStockMovement; import de.effigenix.application.inventory.RecordStockMovement;
import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.AddStockBatch;
@ -117,6 +118,13 @@ public class InventoryUseCaseConfiguration {
return new ReleaseReservation(stockRepository, unitOfWork); return new ReleaseReservation(stockRepository, unitOfWork);
} }
@Bean
public ConfirmReservation confirmReservation(StockRepository stockRepository,
StockMovementRepository stockMovementRepository,
UnitOfWork unitOfWork) {
return new ConfirmReservation(stockRepository, stockMovementRepository, unitOfWork);
}
@Bean @Bean
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository); return new CheckStockExpiry(stockRepository);

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.ConfirmReservation;
import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.GetStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
@ -12,6 +13,7 @@ import de.effigenix.application.inventory.ReserveStock;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.AddStockBatchCommand;
import de.effigenix.application.inventory.command.ConfirmReservationCommand;
import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.BlockStockBatchCommand;
import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.CreateStockCommand;
import de.effigenix.application.inventory.command.RemoveStockBatchCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
@ -63,12 +65,14 @@ public class StockController {
private final UnblockStockBatch unblockStockBatch; private final UnblockStockBatch unblockStockBatch;
private final ReserveStock reserveStock; private final ReserveStock reserveStock;
private final ReleaseReservation releaseReservation; private final ReleaseReservation releaseReservation;
private final ConfirmReservation confirmReservation;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum, ListStocksBelowMinimum listStocksBelowMinimum,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
ReserveStock reserveStock, ReleaseReservation releaseReservation) { ReserveStock reserveStock, ReleaseReservation releaseReservation,
ConfirmReservation confirmReservation) {
this.createStock = createStock; this.createStock = createStock;
this.updateStock = updateStock; this.updateStock = updateStock;
this.getStock = getStock; this.getStock = getStock;
@ -80,6 +84,7 @@ public class StockController {
this.unblockStockBatch = unblockStockBatch; this.unblockStockBatch = unblockStockBatch;
this.reserveStock = reserveStock; this.reserveStock = reserveStock;
this.releaseReservation = releaseReservation; this.releaseReservation = releaseReservation;
this.confirmReservation = confirmReservation;
} }
@GetMapping @GetMapping
@ -310,6 +315,26 @@ public class StockController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/{stockId}/reservations/{reservationId}/confirm")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<Void> confirmReservation(
@PathVariable String stockId,
@PathVariable String reservationId,
Authentication authentication
) {
logger.info("Confirming reservation {} of stock {} by actor: {}", reservationId, stockId, authentication.getName());
var cmd = new ConfirmReservationCommand(stockId, reservationId, authentication.getName());
var result = confirmReservation.execute(cmd);
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
logger.info("Reservation {} of stock {} confirmed", reservationId, 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;

View file

@ -0,0 +1,248 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.ConfirmReservationCommand;
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 de.effigenix.shared.persistence.UnitOfWork;
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.ArgumentCaptor;
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("ConfirmReservation Use Case")
class ConfirmReservationTest {
@Mock private StockRepository stockRepository;
@Mock private StockMovementRepository stockMovementRepository;
@Mock private UnitOfWork unitOfWork;
private ConfirmReservation confirmReservation;
@BeforeEach
void setUp() {
confirmReservation = new ConfirmReservation(stockRepository, stockMovementRepository, unitOfWork);
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv ->
((java.util.function.Supplier<?>) inv.getArgument(0)).get());
}
@Test
@DisplayName("should confirm reservation, save stock and create stock movements")
void shouldConfirmReservationSuccessfully() {
var stock = createStockWithReservation("PRODUCTION_ORDER", "PO-001", "10");
var reservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), reservationId, "admin"));
assertThat(result.isSuccess()).isTrue();
verify(stockRepository).save(stock);
var movementCaptor = ArgumentCaptor.forClass(StockMovement.class);
verify(stockMovementRepository).save(movementCaptor.capture());
var movement = movementCaptor.getValue();
assertThat(movement.movementType()).isEqualTo(MovementType.PRODUCTION_CONSUMPTION);
assertThat(movement.direction()).isEqualTo(MovementDirection.OUT);
assertThat(movement.quantity().amount()).isEqualByComparingTo(new BigDecimal("10"));
assertThat(movement.performedBy()).isEqualTo("admin");
}
@Test
@DisplayName("should derive SALE movement type from SALE_ORDER reference type")
void shouldDeriveSaleMovementType() {
var stock = createStockWithReservation("SALE_ORDER", "SO-001", "5");
var reservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), reservationId, "admin"));
assertThat(result.isSuccess()).isTrue();
var movementCaptor = ArgumentCaptor.forClass(StockMovement.class);
verify(stockMovementRepository).save(movementCaptor.capture());
assertThat(movementCaptor.getValue().movementType()).isEqualTo(MovementType.SALE);
}
@Test
@DisplayName("should create separate movements for multi-batch reservation")
void shouldCreateSeparateMovementsForMultiBatch() {
var batch1 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
);
var batch2 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.of("stock-1"),
de.effigenix.domain.masterdata.ArticleId.of("article-1"),
StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
);
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
var reservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var result = confirmReservation.execute(
new ConfirmReservationCommand("stock-1", reservationId, "admin"));
assertThat(result.isSuccess()).isTrue();
verify(stockMovementRepository, times(2)).save(any());
}
@Test
@DisplayName("should fail with StockNotFound when stock does not exist")
void shouldFailWhenStockNotFound() {
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.empty()));
var result = confirmReservation.execute(
new ConfirmReservationCommand("stock-1", "res-1", "admin"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class);
verify(stockRepository, never()).save(any());
verify(stockMovementRepository, never()).save(any());
}
@Test
@DisplayName("should fail with ReservationNotFound when reservation does not exist")
void shouldFailWhenReservationNotFound() {
var stock = createStockWithBatch("20");
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), "nonexistent", "admin"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
verify(stockRepository, never()).save(any());
verify(stockMovementRepository, never()).save(any());
}
@Test
@DisplayName("should fail with RepositoryFailure when findById fails")
void shouldFailWhenFindByIdFails() {
when(stockRepository.findById(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = confirmReservation.execute(
new ConfirmReservationCommand("stock-1", "res-1", "admin"));
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() {
var stock = createStockWithReservation("PRODUCTION_ORDER", "PO-001", "10");
var reservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), reservationId, "admin"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail with RepositoryFailure when stockMovementRepository save fails")
void shouldFailWhenStockMovementSaveFails() {
var stock = createStockWithReservation("PRODUCTION_ORDER", "PO-001", "10");
var reservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), reservationId, "admin"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class);
}
@Test
@DisplayName("should confirm only targeted reservation when stock has multiple")
void shouldConfirmOnlyTargetedReservation() {
var stock = createStockWithBatch("50");
stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT"));
assertThat(stock.reservations()).hasSize(2);
var firstReservationId = stock.reservations().getFirst().id().value();
when(stockRepository.findById(any())).thenReturn(Result.success(Optional.of(stock)));
when(stockRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var result = confirmReservation.execute(
new ConfirmReservationCommand(stock.id().value(), firstReservationId, "admin"));
assertThat(result.isSuccess()).isTrue();
assertThat(stock.reservations()).hasSize(1);
assertThat(stock.reservations().getFirst().referenceId()).isEqualTo("SO-001");
}
// ==================== Helpers ====================
private Stock createStockWithBatch(String amount) {
var batch = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal(amount), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31),
StockBatchStatus.AVAILABLE,
Instant.now()
);
return 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)), List.of()
);
}
private Stock createStockWithReservation(String referenceType, String referenceId, String amount) {
var stock = createStockWithBatch("50");
stock.reserve(new ReservationDraft(referenceType, referenceId, amount, "KILOGRAM", "NORMAL"));
return stock;
}
}

View file

@ -1788,6 +1788,126 @@ class StockTest {
} }
} }
// ==================== confirmReservation ====================
@Nested
@DisplayName("confirmReservation()")
class ConfirmReservation {
@Test
@DisplayName("should deduct allocated quantities and remove reservation")
void shouldDeductAllocatedQuantitiesAndRemoveReservation() {
var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var batchId = stock.batches().getFirst().id();
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL"));
assertThat(reserveResult.isSuccess()).isTrue();
var reservationId = reserveResult.unsafeGetValue().id();
var result = stock.confirmReservation(reservationId);
assertThat(result.isSuccess()).isTrue();
var confirmed = result.unsafeGetValue();
assertThat(confirmed.referenceType()).isEqualTo(ReferenceType.PRODUCTION_ORDER);
assertThat(confirmed.referenceId()).isEqualTo("PO-001");
assertThat(confirmed.allocations()).hasSize(1);
assertThat(confirmed.allocations().getFirst().stockBatchId()).isEqualTo(batchId);
assertThat(confirmed.allocations().getFirst().allocatedQuantity().amount())
.isEqualByComparingTo(new BigDecimal("8"));
assertThat(stock.reservations()).isEmpty();
assertThat(stock.batches()).hasSize(1);
assertThat(stock.batches().getFirst().quantity().amount())
.isEqualByComparingTo(new BigDecimal("12"));
}
@Test
@DisplayName("should deduct from multiple batches correctly")
void shouldDeductFromMultipleBatches() {
var batch1 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("6"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 6, 30), StockBatchStatus.AVAILABLE, Instant.now()
);
var batch2 = StockBatch.reconstitute(
StockBatchId.generate(),
new BatchReference("BATCH-002", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("8"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch1, batch2)), List.of()
);
// Reserve 10kg: 6kg from batch1 (FEFO) + 4kg from batch2
var reserveResult = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "10", "KILOGRAM", "NORMAL"));
assertThat(reserveResult.isSuccess()).isTrue();
assertThat(reserveResult.unsafeGetValue().allocations()).hasSize(2);
var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());
assertThat(result.isSuccess()).isTrue();
var confirmed = result.unsafeGetValue();
assertThat(confirmed.referenceType()).isEqualTo(ReferenceType.SALE_ORDER);
assertThat(confirmed.allocations()).hasSize(2);
assertThat(stock.reservations()).isEmpty();
// batch1 fully consumed removed, batch2 has 4kg remaining
assertThat(stock.batches()).hasSize(1);
assertThat(stock.batches().getFirst().quantity().amount())
.isEqualByComparingTo(new BigDecimal("4"));
}
@Test
@DisplayName("should remove batch when quantity reaches zero")
void shouldRemoveBatchWhenQuantityZero() {
var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM,
StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31));
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL"));
assertThat(reserveResult.isSuccess()).isTrue();
var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());
assertThat(result.isSuccess()).isTrue();
assertThat(stock.batches()).isEmpty();
assertThat(stock.reservations()).isEmpty();
}
@Test
@DisplayName("should fail with ReservationNotFound when reservation does not exist")
void shouldFailWhenReservationNotFound() {
var stock = createValidStock();
var result = stock.confirmReservation(ReservationId.of("nonexistent"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class);
}
@Test
@DisplayName("should return correct batch references in confirmed allocations")
void shouldReturnCorrectBatchReferences() {
var batchRef = new BatchReference("MY-BATCH-42", BatchType.PURCHASED);
var batch = StockBatch.reconstitute(
StockBatchId.generate(), batchRef,
Quantity.reconstitute(new BigDecimal("20"), UnitOfMeasure.KILOGRAM),
LocalDate.of(2026, 12, 31), StockBatchStatus.AVAILABLE, Instant.now()
);
var stock = Stock.reconstitute(
StockId.generate(), ArticleId.of("article-1"), StorageLocationId.of("location-1"),
null, null, new ArrayList<>(List.of(batch)), List.of()
);
var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL"));
var result = stock.confirmReservation(reserveResult.unsafeGetValue().id());
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().allocations().getFirst().batchReference()).isEqualTo(batchRef);
}
}
// ==================== releaseReservation ==================== // ==================== releaseReservation ====================
@Nested @Nested

View file

@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* - Story 2.1 Bestandsposition anlegen * - Story 2.1 Bestandsposition anlegen
* - Story 2.2 Charge einbuchen (addBatch) * - Story 2.2 Charge einbuchen (addBatch)
* - Story 2.5 Bestandsposition und Chargen abfragen * - Story 2.5 Bestandsposition und Chargen abfragen
* - Story 4.3 Reservierung bestätigen (Material entnehmen)
*/ */
@DisplayName("Stock Controller Integration Tests") @DisplayName("Stock Controller Integration Tests")
class StockControllerIntegrationTest extends AbstractIntegrationTest { class StockControllerIntegrationTest extends AbstractIntegrationTest {
@ -1480,6 +1481,175 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest {
} }
} }
// ==================== Reservierung bestätigen (confirmReservation) ====================
@Nested
@DisplayName("POST /{stockId}/reservations/{reservationId}/confirm Reservierung bestätigen")
class ConfirmReservationEndpoint {
@Test
@DisplayName("Reservierung bestätigen → 200, Menge abgezogen, Reservation entfernt")
void confirmReservation_returns200() throws Exception {
String stockId = createStock();
addBatchToStock(stockId); // 10 KILOGRAM
// Reservieren: 5 kg
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
var reserveResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String reservationId = objectMapper.readTree(reserveResult.getResponse().getContentAsString()).get("id").asText();
// Bestätigen
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm", stockId, reservationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
// Prüfen: Reservation entfernt, Menge physisch abgezogen
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(jsonPath("$.reservations.length()").value(0))
.andExpect(jsonPath("$.batches[0].quantityAmount").value(5))
.andExpect(jsonPath("$.availableQuantity").value(5));
}
@Test
@DisplayName("Bestätigung der gesamten Charge → Batch wird entfernt")
void confirmReservation_fullBatch_removesBatch() throws Exception {
String stockId = createStock();
addBatchToStock(stockId); // 10 KILOGRAM
// Alles reservieren
var request = new ReserveStockRequest("SALE_ORDER", "SO-001", "10", "KILOGRAM", "NORMAL");
var reserveResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String reservationId = objectMapper.readTree(reserveResult.getResponse().getContentAsString()).get("id").asText();
// Bestätigen
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm", stockId, reservationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
// Batch entfernt
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(jsonPath("$.reservations.length()").value(0))
.andExpect(jsonPath("$.batches.length()").value(0))
.andExpect(jsonPath("$.availableQuantity").value(0));
}
@Test
@DisplayName("Reservierung nicht gefunden → 404")
void confirmReservation_notFound_returns404() throws Exception {
String stockId = createStock();
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm",
stockId, UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND"));
}
@Test
@DisplayName("Stock nicht gefunden → 404")
void confirmReservation_stockNotFound_returns404() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("STOCK_NOT_FOUND"));
}
@Test
@DisplayName("Ohne STOCK_WRITE → 403")
void confirmReservation_withViewerToken_returns403() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm",
UUID.randomUUID().toString(), UUID.randomUUID().toString())
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Ohne Token → 401")
void confirmReservation_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm",
UUID.randomUUID().toString(), UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Idempotenz: zweites Confirm auf gleiche Reservierung → 404")
void confirmReservation_idempotent_returns404OnSecondCall() throws Exception {
String stockId = createStock();
addBatchToStock(stockId);
var request = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "5", "KILOGRAM", "NORMAL");
var reserveResult = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String reservationId = objectMapper.readTree(reserveResult.getResponse().getContentAsString()).get("id").asText();
// Erstes Confirm 200
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm", stockId, reservationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
// Zweites Confirm 404
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm", stockId, reservationId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND"));
}
@Test
@DisplayName("Mehrere Reservierungen: eine bestätigen, andere bleibt bestehen")
void confirmReservation_multipleReservations_keepsOthers() throws Exception {
String stockId = createStock();
addBatchToStock(stockId); // 10 KILOGRAM
// Reservierung 1: 4 kg
var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "4", "KILOGRAM", "NORMAL");
var res1 = mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req1)))
.andExpect(status().isCreated())
.andReturn();
String reservationId1 = objectMapper.readTree(res1.getResponse().getContentAsString()).get("id").asText();
// Reservierung 2: 3 kg
var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "3", "KILOGRAM", "URGENT");
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req2)))
.andExpect(status().isCreated());
// Erste Reservierung bestätigen
mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations/{reservationId}/confirm", stockId, reservationId1)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
// Batch: 10 - 4 = 6 kg, SO-001 bleibt
mockMvc.perform(get("/api/inventory/stocks/{id}", stockId)
.header("Authorization", "Bearer " + adminToken))
.andExpect(jsonPath("$.batches[0].quantityAmount").value(6))
.andExpect(jsonPath("$.reservations.length()").value(1))
.andExpect(jsonPath("$.reservations[0].referenceId").value("SO-001"))
.andExpect(jsonPath("$.availableQuantity").value(3));
}
}
// ==================== Hilfsmethoden ==================== // ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception { private String createStorageLocation() throws Exception {

View file

@ -125,6 +125,34 @@ public final class InventoryScenario {
); );
} }
public static ChainBuilder reserveAndConfirmStock() {
return exec(session -> {
var stockIds = LoadTestDataSeeder.stockIds();
if (stockIds == null || stockIds.isEmpty()) return session;
String id = stockIds.get(ThreadLocalRandom.current().nextInt(stockIds.size()));
return session
.set("reserveStockId", id)
.set("reserveRefId", "LT-PO-%06d".formatted(ThreadLocalRandom.current().nextInt(999999)));
}).doIf(session -> session.contains("reserveStockId")).then(
exec(
http("Reservierung anlegen")
.post("/api/inventory/stocks/#{reserveStockId}/reservations")
.header("Authorization", "Bearer #{accessToken}")
.body(StringBody("""
{"referenceType":"PRODUCTION_ORDER","referenceId":"#{reserveRefId}","quantityAmount":"1","quantityUnit":"KILOGRAM","priority":"NORMAL"}"""))
.check(status().in(201, 409))
.check(jsonPath("$.id").optional().saveAs("reservationId"))
).doIf(session -> session.contains("reservationId")).then(
exec(
http("Reservierung bestätigen")
.post("/api/inventory/stocks/#{reserveStockId}/reservations/#{reservationId}/confirm")
.header("Authorization", "Bearer #{accessToken}")
.check(status().in(200, 404))
).exec(session -> session.remove("reservationId"))
)
);
}
public static ChainBuilder recordStockMovement() { public static ChainBuilder recordStockMovement() {
return exec(session -> { return exec(session -> {
var rnd = ThreadLocalRandom.current(); var rnd = ThreadLocalRandom.current();
@ -170,7 +198,8 @@ public final class InventoryScenario {
percent(5.0).then(listStockMovementsByStock()), percent(5.0).then(listStockMovementsByStock()),
percent(5.0).then(listStockMovementsByBatch()), percent(5.0).then(listStockMovementsByBatch()),
percent(5.0).then(listStockMovementsByDateRange()), percent(5.0).then(listStockMovementsByDateRange()),
percent(15.0).then(recordStockMovement()) percent(8.0).then(reserveAndConfirmStock()),
percent(7.0).then(recordStockMovement())
).pause(1, 3) ).pause(1, 3)
); );
} }