mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 11:59:35 +01:00
feat(inventory): Reservierung bestätigen – Material entnehmen (US-4.3)
Allokierte Mengen werden physisch aus den Chargen abgezogen, StockMovements erzeugt und die Reservation entfernt. MovementType wird aus ReferenceType abgeleitet (PRODUCTION_ORDER→PRODUCTION_CONSUMPTION, SALE_ORDER→SALE).
This commit is contained in:
parent
19f1cf16a1
commit
0b6028b967
11 changed files with 773 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue