1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 11:59:35 +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,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 ====================
private Stock createValidStock() {

View file

@ -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 {