mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:09: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,93 @@
|
||||||
|
package de.effigenix.application.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.application.inventory.command.ConfirmReservationCommand;
|
||||||
|
import de.effigenix.domain.inventory.*;
|
||||||
|
import de.effigenix.shared.common.Result;
|
||||||
|
import de.effigenix.shared.persistence.UnitOfWork;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ConfirmReservation {
|
||||||
|
|
||||||
|
private final StockRepository stockRepository;
|
||||||
|
private final StockMovementRepository stockMovementRepository;
|
||||||
|
private final UnitOfWork unitOfWork;
|
||||||
|
|
||||||
|
public ConfirmReservation(StockRepository stockRepository,
|
||||||
|
StockMovementRepository stockMovementRepository,
|
||||||
|
UnitOfWork unitOfWork) {
|
||||||
|
this.stockRepository = stockRepository;
|
||||||
|
this.stockMovementRepository = stockMovementRepository;
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<StockError, Void> execute(ConfirmReservationCommand cmd) {
|
||||||
|
// 1. Stock laden
|
||||||
|
Stock stock;
|
||||||
|
switch (stockRepository.findById(StockId.of(cmd.stockId()))) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var opt) -> {
|
||||||
|
if (opt.isEmpty()) {
|
||||||
|
return Result.failure(new StockError.StockNotFound(cmd.stockId()));
|
||||||
|
}
|
||||||
|
stock = opt.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reservation bestätigen
|
||||||
|
ConfirmedReservation confirmed;
|
||||||
|
switch (stock.confirmReservation(ReservationId.of(cmd.reservationId()))) {
|
||||||
|
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||||
|
case Result.Success(var val) -> confirmed = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. MovementType aus ReferenceType ableiten
|
||||||
|
String movementType = switch (confirmed.referenceType()) {
|
||||||
|
case PRODUCTION_ORDER -> "PRODUCTION_CONSUMPTION";
|
||||||
|
case SALE_ORDER -> "SALE";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. StockMovements erzeugen
|
||||||
|
List<StockMovement> movements = new ArrayList<>();
|
||||||
|
for (ConfirmedAllocation alloc : confirmed.allocations()) {
|
||||||
|
var draft = new StockMovementDraft(
|
||||||
|
stock.id().value(),
|
||||||
|
stock.articleId().value(),
|
||||||
|
alloc.stockBatchId().value(),
|
||||||
|
alloc.batchReference().batchId(),
|
||||||
|
alloc.batchReference().batchType().name(),
|
||||||
|
movementType,
|
||||||
|
null,
|
||||||
|
alloc.allocatedQuantity().amount().toPlainString(),
|
||||||
|
alloc.allocatedQuantity().uom().name(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
cmd.performedBy()
|
||||||
|
);
|
||||||
|
switch (StockMovement.record(draft)) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var val) -> movements.add(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Atomar speichern
|
||||||
|
return unitOfWork.executeAtomically(() -> {
|
||||||
|
switch (stockRepository.save(stock)) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var ignored) -> { }
|
||||||
|
}
|
||||||
|
for (StockMovement movement : movements) {
|
||||||
|
switch (stockMovementRepository.save(movement)) {
|
||||||
|
case Result.Failure(var err) ->
|
||||||
|
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
|
||||||
|
case Result.Success(var ignored) -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.success(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package de.effigenix.application.inventory.command;
|
||||||
|
|
||||||
|
public record ConfirmReservationCommand(String stockId, String reservationId, String performedBy) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import de.effigenix.shared.common.Quantity;
|
||||||
|
|
||||||
|
public record ConfirmedAllocation(
|
||||||
|
StockBatchId stockBatchId,
|
||||||
|
BatchReference batchReference,
|
||||||
|
Quantity allocatedQuantity
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package de.effigenix.domain.inventory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ConfirmedReservation(
|
||||||
|
ReferenceType referenceType,
|
||||||
|
String referenceId,
|
||||||
|
List<ConfirmedAllocation> allocations
|
||||||
|
) {}
|
||||||
|
|
@ -34,6 +34,8 @@ import java.util.stream.Collectors;
|
||||||
* - 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
|
* - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities
|
||||||
|
* - confirmReservation: deducts allocated quantities from batches, removes reservation, returns ConfirmedReservation
|
||||||
|
* - batches with quantity 0 after deduction are removed
|
||||||
* - reservations track allocated quantities per batch; no over-reservation possible
|
* - reservations track allocated quantities per batch; no over-reservation possible
|
||||||
*/
|
*/
|
||||||
public class Stock {
|
public class Stock {
|
||||||
|
|
@ -354,6 +356,61 @@ public class Stock {
|
||||||
return Result.success(reservation);
|
return Result.success(reservation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result<StockError, ConfirmedReservation> confirmReservation(ReservationId reservationId) {
|
||||||
|
// 1. Reservation finden
|
||||||
|
Reservation reservation = reservations.stream()
|
||||||
|
.filter(r -> r.id().equals(reservationId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (reservation == null) {
|
||||||
|
return Result.failure(new StockError.ReservationNotFound(reservationId.value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Für jede Allocation: Batch finden und BatchReference merken
|
||||||
|
List<ConfirmedAllocation> confirmedAllocations = new ArrayList<>();
|
||||||
|
for (StockBatchAllocation alloc : reservation.allocations()) {
|
||||||
|
StockBatch batch = batches.stream()
|
||||||
|
.filter(b -> b.id().equals(alloc.stockBatchId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (batch == null) {
|
||||||
|
return Result.failure(new StockError.BatchNotFound(alloc.stockBatchId().value()));
|
||||||
|
}
|
||||||
|
confirmedAllocations.add(new ConfirmedAllocation(
|
||||||
|
batch.id(), batch.batchReference(), alloc.allocatedQuantity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Für jede Allocation: Menge abziehen, bei 0 Batch entfernen
|
||||||
|
for (ConfirmedAllocation confirmed : confirmedAllocations) {
|
||||||
|
StockBatch batch = batches.stream()
|
||||||
|
.filter(b -> b.id().equals(confirmed.stockBatchId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (batch == null) {
|
||||||
|
return Result.failure(new StockError.BatchNotFound(confirmed.stockBatchId().value()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Quantity remaining;
|
||||||
|
switch (batch.removeQuantity(confirmed.allocatedQuantity())) {
|
||||||
|
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||||
|
case Result.Success(var val) -> remaining = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.amount().signum() == 0) {
|
||||||
|
this.batches.remove(batch);
|
||||||
|
} else {
|
||||||
|
int index = this.batches.indexOf(batch);
|
||||||
|
this.batches.set(index, batch.withQuantity(remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Reservation entfernen
|
||||||
|
this.reservations.remove(reservation);
|
||||||
|
|
||||||
|
return Result.success(new ConfirmedReservation(
|
||||||
|
reservation.referenceType(), reservation.referenceId(), confirmedAllocations));
|
||||||
|
}
|
||||||
|
|
||||||
public Result<StockError, Void> releaseReservation(ReservationId reservationId) {
|
public Result<StockError, Void> releaseReservation(ReservationId reservationId) {
|
||||||
Reservation reservation = reservations.stream()
|
Reservation reservation = reservations.stream()
|
||||||
.filter(r -> r.id().equals(reservationId))
|
.filter(r -> r.id().equals(reservationId))
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package de.effigenix.infrastructure.config;
|
||||||
|
|
||||||
import de.effigenix.application.inventory.GetStockMovement;
|
import de.effigenix.application.inventory.GetStockMovement;
|
||||||
import de.effigenix.application.inventory.ListStockMovements;
|
import de.effigenix.application.inventory.ListStockMovements;
|
||||||
|
import de.effigenix.application.inventory.ConfirmReservation;
|
||||||
import de.effigenix.application.inventory.RecordStockMovement;
|
import de.effigenix.application.inventory.RecordStockMovement;
|
||||||
import de.effigenix.application.inventory.ActivateStorageLocation;
|
import de.effigenix.application.inventory.ActivateStorageLocation;
|
||||||
import de.effigenix.application.inventory.AddStockBatch;
|
import de.effigenix.application.inventory.AddStockBatch;
|
||||||
|
|
@ -117,6 +118,13 @@ public class InventoryUseCaseConfiguration {
|
||||||
return new ReleaseReservation(stockRepository, unitOfWork);
|
return new ReleaseReservation(stockRepository, unitOfWork);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ConfirmReservation confirmReservation(StockRepository stockRepository,
|
||||||
|
StockMovementRepository stockMovementRepository,
|
||||||
|
UnitOfWork unitOfWork) {
|
||||||
|
return new ConfirmReservation(stockRepository, stockMovementRepository, unitOfWork);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
|
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
|
||||||
return new CheckStockExpiry(stockRepository);
|
return new CheckStockExpiry(stockRepository);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package de.effigenix.infrastructure.inventory.web.controller;
|
||||||
|
|
||||||
import de.effigenix.application.inventory.AddStockBatch;
|
import de.effigenix.application.inventory.AddStockBatch;
|
||||||
import de.effigenix.application.inventory.BlockStockBatch;
|
import de.effigenix.application.inventory.BlockStockBatch;
|
||||||
|
import de.effigenix.application.inventory.ConfirmReservation;
|
||||||
import de.effigenix.application.inventory.CreateStock;
|
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;
|
||||||
|
|
@ -12,6 +13,7 @@ import de.effigenix.application.inventory.ReserveStock;
|
||||||
import de.effigenix.application.inventory.UnblockStockBatch;
|
import de.effigenix.application.inventory.UnblockStockBatch;
|
||||||
import de.effigenix.application.inventory.UpdateStock;
|
import de.effigenix.application.inventory.UpdateStock;
|
||||||
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
import de.effigenix.application.inventory.command.AddStockBatchCommand;
|
||||||
|
import de.effigenix.application.inventory.command.ConfirmReservationCommand;
|
||||||
import de.effigenix.application.inventory.command.BlockStockBatchCommand;
|
import de.effigenix.application.inventory.command.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;
|
||||||
|
|
@ -63,12 +65,14 @@ public class StockController {
|
||||||
private final UnblockStockBatch unblockStockBatch;
|
private final UnblockStockBatch unblockStockBatch;
|
||||||
private final ReserveStock reserveStock;
|
private final ReserveStock reserveStock;
|
||||||
private final ReleaseReservation releaseReservation;
|
private final ReleaseReservation releaseReservation;
|
||||||
|
private final ConfirmReservation confirmReservation;
|
||||||
|
|
||||||
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, ReleaseReservation releaseReservation) {
|
ReserveStock reserveStock, ReleaseReservation releaseReservation,
|
||||||
|
ConfirmReservation confirmReservation) {
|
||||||
this.createStock = createStock;
|
this.createStock = createStock;
|
||||||
this.updateStock = updateStock;
|
this.updateStock = updateStock;
|
||||||
this.getStock = getStock;
|
this.getStock = getStock;
|
||||||
|
|
@ -80,6 +84,7 @@ public class StockController {
|
||||||
this.unblockStockBatch = unblockStockBatch;
|
this.unblockStockBatch = unblockStockBatch;
|
||||||
this.reserveStock = reserveStock;
|
this.reserveStock = reserveStock;
|
||||||
this.releaseReservation = releaseReservation;
|
this.releaseReservation = releaseReservation;
|
||||||
|
this.confirmReservation = confirmReservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
|
@ -310,6 +315,26 @@ public class StockController {
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{stockId}/reservations/{reservationId}/confirm")
|
||||||
|
@PreAuthorize("hasAuthority('STOCK_WRITE')")
|
||||||
|
public ResponseEntity<Void> confirmReservation(
|
||||||
|
@PathVariable String stockId,
|
||||||
|
@PathVariable String reservationId,
|
||||||
|
Authentication authentication
|
||||||
|
) {
|
||||||
|
logger.info("Confirming reservation {} of stock {} by actor: {}", reservationId, stockId, authentication.getName());
|
||||||
|
|
||||||
|
var cmd = new ConfirmReservationCommand(stockId, reservationId, authentication.getName());
|
||||||
|
var result = confirmReservation.execute(cmd);
|
||||||
|
|
||||||
|
if (result.isFailure()) {
|
||||||
|
throw new StockDomainErrorException(result.unsafeGetError());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Reservation {} of stock {} confirmed", reservationId, stockId);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
public static class StockDomainErrorException extends RuntimeException {
|
public static class StockDomainErrorException extends RuntimeException {
|
||||||
private final StockError error;
|
private final StockError error;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ====================
|
// ==================== releaseReservation ====================
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||||
* - Story 2.1 – Bestandsposition anlegen
|
* - Story 2.1 – Bestandsposition anlegen
|
||||||
* - Story 2.2 – Charge einbuchen (addBatch)
|
* - Story 2.2 – Charge einbuchen (addBatch)
|
||||||
* - Story 2.5 – Bestandsposition und Chargen abfragen
|
* - Story 2.5 – Bestandsposition und Chargen abfragen
|
||||||
|
* - Story 4.3 – Reservierung bestätigen (Material entnehmen)
|
||||||
*/
|
*/
|
||||||
@DisplayName("Stock Controller Integration Tests")
|
@DisplayName("Stock Controller Integration Tests")
|
||||||
class StockControllerIntegrationTest extends AbstractIntegrationTest {
|
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 ====================
|
// ==================== Hilfsmethoden ====================
|
||||||
|
|
||||||
private String createStorageLocation() throws Exception {
|
private String createStorageLocation() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,34 @@ public final class InventoryScenario {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ChainBuilder reserveAndConfirmStock() {
|
||||||
|
return exec(session -> {
|
||||||
|
var stockIds = LoadTestDataSeeder.stockIds();
|
||||||
|
if (stockIds == null || stockIds.isEmpty()) return session;
|
||||||
|
String id = stockIds.get(ThreadLocalRandom.current().nextInt(stockIds.size()));
|
||||||
|
return session
|
||||||
|
.set("reserveStockId", id)
|
||||||
|
.set("reserveRefId", "LT-PO-%06d".formatted(ThreadLocalRandom.current().nextInt(999999)));
|
||||||
|
}).doIf(session -> session.contains("reserveStockId")).then(
|
||||||
|
exec(
|
||||||
|
http("Reservierung anlegen")
|
||||||
|
.post("/api/inventory/stocks/#{reserveStockId}/reservations")
|
||||||
|
.header("Authorization", "Bearer #{accessToken}")
|
||||||
|
.body(StringBody("""
|
||||||
|
{"referenceType":"PRODUCTION_ORDER","referenceId":"#{reserveRefId}","quantityAmount":"1","quantityUnit":"KILOGRAM","priority":"NORMAL"}"""))
|
||||||
|
.check(status().in(201, 409))
|
||||||
|
.check(jsonPath("$.id").optional().saveAs("reservationId"))
|
||||||
|
).doIf(session -> session.contains("reservationId")).then(
|
||||||
|
exec(
|
||||||
|
http("Reservierung bestätigen")
|
||||||
|
.post("/api/inventory/stocks/#{reserveStockId}/reservations/#{reservationId}/confirm")
|
||||||
|
.header("Authorization", "Bearer #{accessToken}")
|
||||||
|
.check(status().in(200, 404))
|
||||||
|
).exec(session -> session.remove("reservationId"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static ChainBuilder recordStockMovement() {
|
public static ChainBuilder recordStockMovement() {
|
||||||
return exec(session -> {
|
return exec(session -> {
|
||||||
var rnd = ThreadLocalRandom.current();
|
var rnd = ThreadLocalRandom.current();
|
||||||
|
|
@ -170,7 +198,8 @@ public final class InventoryScenario {
|
||||||
percent(5.0).then(listStockMovementsByStock()),
|
percent(5.0).then(listStockMovementsByStock()),
|
||||||
percent(5.0).then(listStockMovementsByBatch()),
|
percent(5.0).then(listStockMovementsByBatch()),
|
||||||
percent(5.0).then(listStockMovementsByDateRange()),
|
percent(5.0).then(listStockMovementsByDateRange()),
|
||||||
percent(15.0).then(recordStockMovement())
|
percent(8.0).then(reserveAndConfirmStock()),
|
||||||
|
percent(7.0).then(recordStockMovement())
|
||||||
).pause(1, 3)
|
).pause(1, 3)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue