From 2938628db4962a1fc2659bac163190a4ea942fb9 Mon Sep 17 00:00:00 2001 From: Sebastian Frick Date: Tue, 24 Feb 2026 00:13:39 +0100 Subject: [PATCH] feat(inventory): Reservierung freigeben (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/inventory/stocks/{stockId}/reservations/{reservationId} gibt eine bestehende Reservierung frei und stellt die verfügbare Menge wieder her. Zusätzlich Liquibase-Changeset 025 idempotent gemacht (ON CONFLICT DO NOTHING). --- .../inventory/ReleaseReservation.java | 46 +++++ .../command/ReleaseReservationCommand.java | 3 + .../de/effigenix/domain/inventory/Stock.java | 13 ++ .../config/InventoryUseCaseConfiguration.java | 6 + .../web/controller/StockController.java | 26 ++- .../025-seed-production-order-permissions.xml | 33 +-- .../inventory/ReleaseReservationTest.java | 149 ++++++++++++++ .../effigenix/domain/inventory/StockTest.java | 157 ++++++++++++++ .../web/StockControllerIntegrationTest.java | 193 ++++++++++++++++++ 9 files changed, 601 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/de/effigenix/application/inventory/ReleaseReservation.java create mode 100644 backend/src/main/java/de/effigenix/application/inventory/command/ReleaseReservationCommand.java create mode 100644 backend/src/test/java/de/effigenix/application/inventory/ReleaseReservationTest.java diff --git a/backend/src/main/java/de/effigenix/application/inventory/ReleaseReservation.java b/backend/src/main/java/de/effigenix/application/inventory/ReleaseReservation.java new file mode 100644 index 0000000..c0a4b95 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/ReleaseReservation.java @@ -0,0 +1,46 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.ReleaseReservationCommand; +import de.effigenix.domain.inventory.*; +import de.effigenix.shared.common.Result; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class ReleaseReservation { + + private final StockRepository stockRepository; + + public ReleaseReservation(StockRepository stockRepository) { + this.stockRepository = stockRepository; + } + + public Result execute(ReleaseReservationCommand 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 freigeben + switch (stock.releaseReservation(ReservationId.of(cmd.reservationId()))) { + case Result.Failure(var err) -> { return Result.failure(err); } + case Result.Success(var ignored) -> { } + } + + // 3. 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); + } +} diff --git a/backend/src/main/java/de/effigenix/application/inventory/command/ReleaseReservationCommand.java b/backend/src/main/java/de/effigenix/application/inventory/command/ReleaseReservationCommand.java new file mode 100644 index 0000000..b6ce001 --- /dev/null +++ b/backend/src/main/java/de/effigenix/application/inventory/command/ReleaseReservationCommand.java @@ -0,0 +1,3 @@ +package de.effigenix.application.inventory.command; + +public record ReleaseReservationCommand(String stockId, String reservationId) {} 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 4901a26..9f605ee 100644 --- a/backend/src/main/java/de/effigenix/domain/inventory/Stock.java +++ b/backend/src/main/java/de/effigenix/domain/inventory/Stock.java @@ -33,6 +33,7 @@ import java.util.stream.Collectors; * - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations * - 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 * - reservations track allocated quantities per batch; no over-reservation possible */ public class Stock { @@ -353,6 +354,18 @@ public class Stock { return Result.success(reservation); } + public Result releaseReservation(ReservationId reservationId) { + Reservation reservation = reservations.stream() + .filter(r -> r.id().equals(reservationId)) + .findFirst() + .orElse(null); + if (reservation == null) { + return Result.failure(new StockError.ReservationNotFound(reservationId.value())); + } + this.reservations.remove(reservation); + return Result.success(null); + } + // ==================== Expiry Management ==================== public Result> markExpiredBatches(LocalDate today) { 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 c519690..857ef38 100644 --- a/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java +++ b/backend/src/main/java/de/effigenix/infrastructure/config/InventoryUseCaseConfiguration.java @@ -10,6 +10,7 @@ import de.effigenix.application.inventory.UpdateStock; import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.RemoveStockBatch; +import de.effigenix.application.inventory.ReleaseReservation; import de.effigenix.application.inventory.ReserveStock; import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.CreateStorageLocation; @@ -106,6 +107,11 @@ public class InventoryUseCaseConfiguration { return new ReserveStock(stockRepository); } + @Bean + public ReleaseReservation releaseReservation(StockRepository stockRepository) { + return new ReleaseReservation(stockRepository); + } + @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 8c7d6fe..8fad338 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 @@ -6,6 +6,7 @@ import de.effigenix.application.inventory.CreateStock; import de.effigenix.application.inventory.GetStock; import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocksBelowMinimum; +import de.effigenix.application.inventory.ReleaseReservation; import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.ReserveStock; import de.effigenix.application.inventory.UnblockStockBatch; @@ -14,6 +15,7 @@ import de.effigenix.application.inventory.command.AddStockBatchCommand; import de.effigenix.application.inventory.command.BlockStockBatchCommand; import de.effigenix.application.inventory.command.CreateStockCommand; import de.effigenix.application.inventory.command.RemoveStockBatchCommand; +import de.effigenix.application.inventory.command.ReleaseReservationCommand; import de.effigenix.application.inventory.command.ReserveStockCommand; import de.effigenix.application.inventory.command.UnblockStockBatchCommand; import de.effigenix.application.inventory.command.UpdateStockCommand; @@ -60,12 +62,13 @@ public class StockController { private final BlockStockBatch blockStockBatch; private final UnblockStockBatch unblockStockBatch; private final ReserveStock reserveStock; + private final ReleaseReservation releaseReservation; public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, ListStocksBelowMinimum listStocksBelowMinimum, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch, - ReserveStock reserveStock) { + ReserveStock reserveStock, ReleaseReservation releaseReservation) { this.createStock = createStock; this.updateStock = updateStock; this.getStock = getStock; @@ -76,6 +79,7 @@ public class StockController { this.blockStockBatch = blockStockBatch; this.unblockStockBatch = unblockStockBatch; this.reserveStock = reserveStock; + this.releaseReservation = releaseReservation; } @GetMapping @@ -286,6 +290,26 @@ public class StockController { .body(ReservationResponse.from(result.unsafeGetValue())); } + @DeleteMapping("/{stockId}/reservations/{reservationId}") + @PreAuthorize("hasAuthority('STOCK_WRITE')") + public ResponseEntity releaseReservation( + @PathVariable String stockId, + @PathVariable String reservationId, + Authentication authentication + ) { + logger.info("Releasing reservation {} of stock {} by actor: {}", reservationId, stockId, authentication.getName()); + + var cmd = new ReleaseReservationCommand(stockId, reservationId); + var result = releaseReservation.execute(cmd); + + if (result.isFailure()) { + throw new StockDomainErrorException(result.unsafeGetError()); + } + + logger.info("Reservation {} of stock {} released", reservationId, stockId); + return ResponseEntity.noContent().build(); + } + public static class StockDomainErrorException extends RuntimeException { private final StockError error; diff --git a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml index f60b9da..f926a71 100644 --- a/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml +++ b/backend/src/main/resources/db/changelog/changes/025-seed-production-order-permissions.xml @@ -6,31 +6,16 @@ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles + Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (idempotent) - - - - - - - - - - - - - - - - - - - - - - - + + INSERT INTO role_permissions (role_id, permission) VALUES + ('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_READ'), + ('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_WRITE'), + ('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_READ'), + ('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_WRITE') + ON CONFLICT DO NOTHING; + diff --git a/backend/src/test/java/de/effigenix/application/inventory/ReleaseReservationTest.java b/backend/src/test/java/de/effigenix/application/inventory/ReleaseReservationTest.java new file mode 100644 index 0000000..5a15b34 --- /dev/null +++ b/backend/src/test/java/de/effigenix/application/inventory/ReleaseReservationTest.java @@ -0,0 +1,149 @@ +package de.effigenix.application.inventory; + +import de.effigenix.application.inventory.command.ReleaseReservationCommand; +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("ReleaseReservation Use Case") +class ReleaseReservationTest { + + @Mock private StockRepository stockRepository; + + private ReleaseReservation releaseReservation; + private Stock existingStock; + private String reservationId; + + @BeforeEach + void setUp() { + releaseReservation = new ReleaseReservation(stockRepository); + + var batch = StockBatch.reconstitute( + StockBatchId.generate(), + new BatchReference("BATCH-001", BatchType.PRODUCED), + Quantity.reconstitute(new BigDecimal("50"), UnitOfMeasure.KILOGRAM), + 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)), List.of() + ); + + // Create a reservation + var reserveResult = existingStock.reserve( + new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL")); + reservationId = reserveResult.unsafeGetValue().id().value(); + } + + @Test + @DisplayName("should release reservation successfully") + void shouldReleaseReservationSuccessfully() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(existingStock.reservations()).isEmpty(); + 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 = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.StockNotFound.class); + verify(stockRepository, never()).save(any()); + } + + @Test + @DisplayName("should fail with ReservationNotFound when reservation does not exist in stock") + void shouldFailWhenReservationNotFoundInStock() { + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + + var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", "nonexistent")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.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 = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId)); + + 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 = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId)); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.RepositoryFailure.class); + } + + @Test + @DisplayName("should release only targeted reservation when stock has multiple") + void shouldReleaseOnlyTargetedReservation() { + // Add a second reservation + var res2 = existingStock.reserve( + new ReservationDraft("SALE_ORDER", "SO-001", "5", "KILOGRAM", "URGENT")); + var secondReservationId = res2.unsafeGetValue().id().value(); + assertThat(existingStock.reservations()).hasSize(2); + + when(stockRepository.findById(StockId.of("stock-1"))) + .thenReturn(Result.success(Optional.of(existingStock))); + when(stockRepository.save(any())).thenReturn(Result.success(null)); + + var result = releaseReservation.execute(new ReleaseReservationCommand("stock-1", reservationId)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(existingStock.reservations()).hasSize(1); + assertThat(existingStock.reservations().getFirst().id().value()).isEqualTo(secondReservationId); + verify(stockRepository).save(existingStock); + } +} 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 88e58e9..a30bfca 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,163 @@ class StockTest { } } + // ==================== releaseReservation ==================== + + @Nested + @DisplayName("releaseReservation()") + class ReleaseReservation { + + @Test + @DisplayName("should release reservation and restore available quantity") + void shouldReleaseReservationAndRestoreAvailableQuantity() { + var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL")); + assertThat(reserveResult.isSuccess()).isTrue(); + var reservationId = reserveResult.unsafeGetValue().id(); + + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("12")); + assertThat(stock.reservations()).hasSize(1); + + var result = stock.releaseReservation(reservationId); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.reservations()).isEmpty(); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("20")); + } + + @Test + @DisplayName("should fail with ReservationNotFound when reservation does not exist") + void shouldFailWhenReservationNotFound() { + var stock = createValidStock(); + + var result = stock.releaseReservation(ReservationId.of("nonexistent")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class); + } + + @Test + @DisplayName("should keep other reservations intact when releasing one of multiple") + void shouldKeepOtherReservationsWhenReleasingOne() { + var stock = createStockWithBatchAndExpiry("20", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "8", "KILOGRAM", "NORMAL")); + var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "7", "KILOGRAM", "URGENT")); + assertThat(stock.reservations()).hasSize(2); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5")); + + var result = stock.releaseReservation(res1.unsafeGetValue().id()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.reservations()).hasSize(1); + assertThat(stock.reservations().getFirst().referenceId()).isEqualTo("SO-001"); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("13")); + } + + @Test + @DisplayName("should release reservation with cross-batch FEFO allocations") + void shouldReleaseCrossBatchAllocations() { + 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() + ); + + // Reservierung über beide Batches (6kg batch1 + 4kg batch2) + var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL")); + assertThat(reserveResult.unsafeGetValue().allocations()).hasSize(2); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("4")); + + var result = stock.releaseReservation(reserveResult.unsafeGetValue().id()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.reservations()).isEmpty(); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("14")); + } + + @Test + @DisplayName("should allow new reservation after releasing previous one") + void shouldAllowNewReservationAfterRelease() { + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + // Vollständig reservieren + var res1 = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "10", "KILOGRAM", "NORMAL")); + assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO); + + // Neue Reservierung sollte fehlschlagen + var failResult = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "1", "KILOGRAM", "NORMAL")); + assertThat(failResult.isFailure()).isTrue(); + + // Freigeben und erneut reservieren + stock.releaseReservation(res1.unsafeGetValue().id()); + var res2 = stock.reserve(new ReservationDraft("SALE_ORDER", "SO-001", "10", "KILOGRAM", "URGENT")); + assertThat(res2.isSuccess()).isTrue(); + assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("should handle release when batch was later blocked") + void shouldReleaseWhenBatchLaterBlocked() { + 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", "5", "KILOGRAM", "NORMAL")); + stock.blockBatch(batchId); + + // availableQuantity == 0 (blocked batch) + assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO); + + var result = stock.releaseReservation(reserveResult.unsafeGetValue().id()); + + assertThat(result.isSuccess()).isTrue(); + assertThat(stock.reservations()).isEmpty(); + // Batch still blocked → availableQuantity remains 0 + assertThat(stock.availableQuantity()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("should fail with ReservationNotFound on empty reservations list") + void shouldFailOnEmptyReservationsList() { + var stock = createStockWithBatchAndExpiry("10", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + var result = stock.releaseReservation(ReservationId.of("any-id")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.unsafeGetError()).isInstanceOf(StockError.ReservationNotFound.class); + } + + @Test + @DisplayName("should restore fractional quantity after release") + void shouldRestoreFractionalQuantity() { + var stock = createStockWithBatchAndExpiry("10.5", UnitOfMeasure.KILOGRAM, + StockBatchStatus.AVAILABLE, LocalDate.of(2026, 12, 31)); + + var reserveResult = stock.reserve(new ReservationDraft("PRODUCTION_ORDER", "PO-001", "5.25", "KILOGRAM", "NORMAL")); + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("5.25")); + + stock.releaseReservation(reserveResult.unsafeGetValue().id()); + + assertThat(stock.availableQuantity()).isEqualByComparingTo(new BigDecimal("10.5")); + } + } + // ==================== Helpers ==================== private Stock createValidStock() { 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 e017318..fe0a1d0 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 @@ -16,6 +16,7 @@ import org.springframework.http.MediaType; import java.util.Set; import java.util.UUID; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @@ -1197,6 +1198,198 @@ class StockControllerIntegrationTest extends AbstractIntegrationTest { } } + // ==================== Reservierung freigeben (releaseReservation) ==================== + + @Nested + @DisplayName("DELETE /{stockId}/reservations/{reservationId} – Reservierung freigeben") + class ReleaseReservationEndpoint { + + @Test + @DisplayName("Reservierung freigeben → 204, availableQuantity steigt") + void releaseReservation_returns204() throws Exception { + String stockId = createStock(); + addBatchToStock(stockId); // 10 KILOGRAM + + // Erst reservieren + 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(); + + // availableQuantity == 5 + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.availableQuantity").value(5)); + + // Reservierung freigeben + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // availableQuantity == 10 (wiederhergestellt) + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.availableQuantity").value(10)) + .andExpect(jsonPath("$.reservations.length()").value(0)); + } + + @Test + @DisplayName("Reservierung nicht gefunden → 404") + void releaseReservation_notFound_returns404() throws Exception { + String stockId = createStock(); + + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", 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 releaseReservation_stockNotFound_returns404() throws Exception { + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", + 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 releaseReservation_withViewerToken_returns403() throws Exception { + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", + UUID.randomUUID().toString(), UUID.randomUUID().toString()) + .header("Authorization", "Bearer " + viewerToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Ohne Token → 401") + void releaseReservation_withoutToken_returns401() throws Exception { + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", + UUID.randomUUID().toString(), UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Mehrere Reservierungen: eine freigeben, andere bleibt bestehen") + void releaseReservation_multipleReservations_keepsOthers() throws Exception { + String stockId = createStock(); + addBatchToStock(stockId); // 10 KILOGRAM + + // Reservierung 1: 6 kg + var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "6", "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: 4 kg + var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "4", "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()); + + // availableQuantity == 0 + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.availableQuantity").value(0)) + .andExpect(jsonPath("$.reservations.length()").value(2)); + + // Erste Reservierung freigeben + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId1) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // Nur 6 kg frei, SO-001 bleibt + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.availableQuantity").value(6)) + .andExpect(jsonPath("$.reservations.length()").value(1)) + .andExpect(jsonPath("$.reservations[0].referenceId").value("SO-001")); + } + + @Test + @DisplayName("Idempotenz: zweites DELETE auf gleiche Reservierung → 404") + void releaseReservation_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 DELETE → 204 + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // Zweites DELETE → 404 + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("RESERVATION_NOT_FOUND")); + } + + @Test + @DisplayName("Reserve → Release → erneut Reserve → verfügbare Menge korrekt") + void releaseReservation_thenReserveAgain() throws Exception { + String stockId = createStock(); + addBatchToStock(stockId); // 10 KILOGRAM + + // Alles reservieren + var req1 = new ReserveStockRequest("PRODUCTION_ORDER", "PO-001", "10", "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 reservationId = objectMapper.readTree(res1.getResponse().getContentAsString()).get("id").asText(); + + // Erneute Reservierung schlägt fehl + var req2 = new ReserveStockRequest("SALE_ORDER", "SO-001", "1", "KILOGRAM", "NORMAL"); + mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req2))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK")); + + // Freigeben + mockMvc.perform(delete("/api/inventory/stocks/{stockId}/reservations/{reservationId}", stockId, reservationId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + // Jetzt geht es wieder + mockMvc.perform(post("/api/inventory/stocks/{stockId}/reservations", stockId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req2))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/inventory/stocks/{id}", stockId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.availableQuantity").value(9)) + .andExpect(jsonPath("$.reservations.length()").value(1)) + .andExpect(jsonPath("$.reservations[0].referenceId").value("SO-001")); + } + } + // ==================== Hilfsmethoden ==================== private String createStorageLocation() throws Exception {