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

feat(inventory): Reservierung freigeben (#13)

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).
This commit is contained in:
Sebastian Frick 2026-02-24 00:13:39 +01:00
parent 0b49bb2977
commit 2938628db4
9 changed files with 601 additions and 25 deletions

View file

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

View file

@ -0,0 +1,3 @@
package de.effigenix.application.inventory.command;
public record ReleaseReservationCommand(String stockId, String reservationId) {}

View file

@ -33,6 +33,7 @@ import java.util.stream.Collectors;
* - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations * - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations
* - 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
* - 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 {
@ -353,6 +354,18 @@ public class Stock {
return Result.success(reservation); return Result.success(reservation);
} }
public Result<StockError, Void> 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 ==================== // ==================== Expiry Management ====================
public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) { public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {

View file

@ -10,6 +10,7 @@ import de.effigenix.application.inventory.UpdateStock;
import de.effigenix.application.inventory.ListStocks; import de.effigenix.application.inventory.ListStocks;
import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.ReleaseReservation;
import de.effigenix.application.inventory.ReserveStock; import de.effigenix.application.inventory.ReserveStock;
import de.effigenix.application.inventory.UnblockStockBatch; import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation; import de.effigenix.application.inventory.CreateStorageLocation;
@ -106,6 +107,11 @@ public class InventoryUseCaseConfiguration {
return new ReserveStock(stockRepository); return new ReserveStock(stockRepository);
} }
@Bean
public ReleaseReservation releaseReservation(StockRepository stockRepository) {
return new ReleaseReservation(stockRepository);
}
@Bean @Bean
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) { public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository); return new CheckStockExpiry(stockRepository);

View file

@ -6,6 +6,7 @@ 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;
import de.effigenix.application.inventory.ListStocksBelowMinimum; import de.effigenix.application.inventory.ListStocksBelowMinimum;
import de.effigenix.application.inventory.ReleaseReservation;
import de.effigenix.application.inventory.RemoveStockBatch; import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.ReserveStock; import de.effigenix.application.inventory.ReserveStock;
import de.effigenix.application.inventory.UnblockStockBatch; 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.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;
import de.effigenix.application.inventory.command.ReleaseReservationCommand;
import de.effigenix.application.inventory.command.ReserveStockCommand; import de.effigenix.application.inventory.command.ReserveStockCommand;
import de.effigenix.application.inventory.command.UnblockStockBatchCommand; import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
import de.effigenix.application.inventory.command.UpdateStockCommand; import de.effigenix.application.inventory.command.UpdateStockCommand;
@ -60,12 +62,13 @@ public class StockController {
private final BlockStockBatch blockStockBatch; private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch; private final UnblockStockBatch unblockStockBatch;
private final ReserveStock reserveStock; private final ReserveStock reserveStock;
private final ReleaseReservation releaseReservation;
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) { ReserveStock reserveStock, ReleaseReservation releaseReservation) {
this.createStock = createStock; this.createStock = createStock;
this.updateStock = updateStock; this.updateStock = updateStock;
this.getStock = getStock; this.getStock = getStock;
@ -76,6 +79,7 @@ public class StockController {
this.blockStockBatch = blockStockBatch; this.blockStockBatch = blockStockBatch;
this.unblockStockBatch = unblockStockBatch; this.unblockStockBatch = unblockStockBatch;
this.reserveStock = reserveStock; this.reserveStock = reserveStock;
this.releaseReservation = releaseReservation;
} }
@GetMapping @GetMapping
@ -286,6 +290,26 @@ public class StockController {
.body(ReservationResponse.from(result.unsafeGetValue())); .body(ReservationResponse.from(result.unsafeGetValue()));
} }
@DeleteMapping("/{stockId}/reservations/{reservationId}")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<Void> 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 { public static class StockDomainErrorException extends RuntimeException {
private final StockError error; private final StockError error;

View file

@ -6,31 +6,16 @@
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="025-seed-production-order-permissions" author="effigenix"> <changeSet id="025-seed-production-order-permissions" author="effigenix">
<comment>Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles</comment> <comment>Add PRODUCTION_ORDER_READ and PRODUCTION_ORDER_WRITE permissions for ADMIN and PRODUCTION_MANAGER roles (idempotent)</comment>
<!-- ADMIN: PRODUCTION_ORDER_READ --> <sql>
<insert tableName="role_permissions"> INSERT INTO role_permissions (role_id, permission) VALUES
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/> ('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_READ'),
<column name="permission" value="PRODUCTION_ORDER_READ"/> ('c0a80121-0000-0000-0000-000000000001', 'PRODUCTION_ORDER_WRITE'),
</insert> ('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_READ'),
('c0a80121-0000-0000-0000-000000000002', 'PRODUCTION_ORDER_WRITE')
<!-- ADMIN: PRODUCTION_ORDER_WRITE --> ON CONFLICT DO NOTHING;
<insert tableName="role_permissions"> </sql>
<column name="role_id" value="c0a80121-0000-0000-0000-000000000001"/>
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
</insert>
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_READ -->
<insert tableName="role_permissions">
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
<column name="permission" value="PRODUCTION_ORDER_READ"/>
</insert>
<!-- PRODUCTION_MANAGER: PRODUCTION_ORDER_WRITE -->
<insert tableName="role_permissions">
<column name="role_id" value="c0a80121-0000-0000-0000-000000000002"/>
<column name="permission" value="PRODUCTION_ORDER_WRITE"/>
</insert>
</changeSet> </changeSet>
</databaseChangeLog> </databaseChangeLog>

View file

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

View file

@ -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 ==================== // ==================== Helpers ====================
private Stock createValidStock() { private Stock createValidStock() {

View file

@ -16,6 +16,7 @@ import org.springframework.http.MediaType;
import java.util.Set; import java.util.Set;
import java.util.UUID; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 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 ==================== // ==================== Hilfsmethoden ====================
private String createStorageLocation() throws Exception { private String createStorageLocation() throws Exception {