diff --git a/backend/src/main/java/de/effigenix/application/inventory/ConfirmReservation.java b/backend/src/main/java/de/effigenix/application/inventory/ConfirmReservation.java new file mode 100644 index 0000000..d6f0214 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ConfirmReservation.java @@ -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 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 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); + }); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/ConfirmReservationCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/ConfirmReservationCommand.java new file mode 100644 index 0000000..af67cbd --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/ConfirmReservationCommand.java @@ -0,0 +1,3 @@ +package de.effigenix.application.inventory.command; + +public record ConfirmReservationCommand(String stockId, String reservationId, String performedBy) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedAllocation.java b/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedAllocation.java new file mode 100644 index 0000000..655e97c --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedAllocation.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.inventory; + +import de.effigenix.shared.common.Quantity; + +public record ConfirmedAllocation( + StockBatchId stockBatchId, + BatchReference batchReference, + Quantity allocatedQuantity +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedReservation.java b/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedReservation.java new file mode 100644 index 0000000..8927519 --- /dev/null +++ b/backend/src/main/java/de/effigenix/domain/inventory/ConfirmedReservation.java @@ -0,0 +1,9 @@ +package de.effigenix.domain.inventory; + +import java.util.List; + +public record ConfirmedReservation( + ReferenceType referenceType, + String referenceId, + List allocations +) {} diff --git a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java index 9f605ee..b069f59 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -34,6 +34,8 @@ import java.util.stream.Collectors; * - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount * - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC * - 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 */ public class Stock { @@ -354,6 +356,61 @@ public class Stock { return Result.success(reservation); } + public Result 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 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 releaseReservation(ReservationId reservationId) { Reservation reservation = reservations.stream() .filter(r -> r.id().equals(reservationId)) diff --git a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java index a07aad3..31833f3 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -2,6 +2,7 @@ package de.effigenix.infrastructure.config; import de.effigenix.application.inventory.GetStockMovement; import de.effigenix.application.inventory.ListStockMovements; +import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.RecordStockMovement; import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.AddStockBatch; @@ -117,6 +118,13 @@ public class InventoryUseCaseConfiguration { return new ReleaseReservation(stockRepository, unitOfWork); } + @Bean + public ConfirmReservation confirmReservation(StockRepository stockRepository, + StockMovementRepository stockMovementRepository, + UnitOfWork unitOfWork) { + return new ConfirmReservation(stockRepository, stockMovementRepository, unitOfWork); + } + @Bean public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { return new CheckStockExpiry(stockRepository); diff --git a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java index 8fad338..45fa8f6 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java +++ b/backend/src/main/java/de/effigenix/infrastructure/inventory/web/controller/StockController.java @@ -2,6 +2,7 @@ package de.effigenix.infrastructure.inventory.web.controller; import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.BlockStockBatch; +import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.GetStock; 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.UpdateStock; 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.CreateStockCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand; @@ -63,12 +65,14 @@ public class StockController { private final UnblockStockBatch unblockStockBatch; private final ReserveStock reserveStock; private final ReleaseReservation releaseReservation; + private final ConfirmReservation confirmReservation; public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, ListStocksBelowMinimum listStocksBelowMinimum, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch, - ReserveStock reserveStock, ReleaseReservation releaseReservation) { + ReserveStock reserveStock, ReleaseReservation releaseReservation, + ConfirmReservation confirmReservation) { this.createStock = createStock; this.updateStock = updateStock; this.getStock = getStock; @@ -80,6 +84,7 @@ public class StockController { this.unblockStockBatch = unblockStockBatch; this.reserveStock = reserveStock; this.releaseReservation = releaseReservation; + this.confirmReservation = confirmReservation; } @GetMapping @@ -310,6 +315,26 @@ public class StockController { return ResponseEntity.noContent().build(); } + @PostMapping("/{stockId}/reservations/{reservationId}/confirm") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity 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 { private final StockError error; diff --git a/backend/src/test/java/de/effigenix/application/inventory/ConfirmReservationTest.java b/backend/src/test/java/de/effigenix/application/inventory/ConfirmReservationTest.java new file mode 100644 index 0000000..736f27a --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ConfirmReservationTest.java @@ -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; + } +} diff --git a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java index a30bfca..117debb 100644 --- a/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java +++ b/backend/src/test/java/de/effigenix/domain/inventory/StockTest.java @@ -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 ==================== @Nested diff --git a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java index 49411aa..28f99be 100644 --- a/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java +++ b/backend/src/test/java/de/effigenix/infrastructure/inventory/web/StockControllerIntegrationTest.java @@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * - Story 2.1 – Bestandsposition anlegen * - Story 2.2 – Charge einbuchen (addBatch) * - Story 2.5 – Bestandsposition und Chargen abfragen + * - Story 4.3 – Reservierung bestätigen (Material entnehmen) */ @DisplayName("Stock Controller Integration Tests") 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 ==================== private String createStorageLocation() throws Exception { diff --git a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java index dd5bc09..7b4844d 100644 --- a/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java +++ b/loadtest/src/test/java/de/effigenix/loadtest/scenario/InventoryScenario.java @@ -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() { return exec(session -> { var rnd = ThreadLocalRandom.current(); @@ -170,7 +198,8 @@ public final class InventoryScenario { percent(5.0).then(listStockMovementsByStock()), percent(5.0).then(listStockMovementsByBatch()), percent(5.0).then(listStockMovementsByDateRange()), - percent(15.0).then(recordStockMovement()) + percent(8.0).then(reserveAndConfirmStock()), + percent(7.0).then(recordStockMovement()) ).pause(1, 3) ); }