1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49: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:
Sebastian Frick 2026-02-25 22:56:45 +01:00
parent 19f1cf16a1
commit 0b6028b967
11 changed files with 773 additions and 2 deletions

View file

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

View file

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

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
import de.effigenix.shared.common.Quantity;
public record ConfirmedAllocation(
StockBatchId stockBatchId,
BatchReference batchReference,
Quantity allocatedQuantity
) {}

View file

@ -0,0 +1,9 @@
package de.effigenix.domain.inventory;
import java.util.List;
public record ConfirmedReservation(
ReferenceType referenceType,
String referenceId,
List<ConfirmedAllocation> allocations
) {}

View file

@ -34,6 +34,8 @@ import java.util.stream.Collectors;
* - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
* - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
* - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities
* - 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
*/
public class Stock {
@ -354,6 +356,61 @@ public class Stock {
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) {
Reservation reservation = reservations.stream()
.filter(r -> r.id().equals(reservationId))

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.ConfirmReservation;
import de.effigenix.application.inventory.RecordStockMovement;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch;
@ -117,6 +118,13 @@ public class InventoryUseCaseConfiguration {
return new ReleaseReservation(stockRepository, unitOfWork);
}
@Bean
public ConfirmReservation confirmReservation(StockRepository stockRepository,
StockMovementRepository stockMovementRepository,
UnitOfWork unitOfWork) {
return new ConfirmReservation(stockRepository, stockMovementRepository, unitOfWork);
}
@Bean
public CheckStockExpiry checkStockExpiry(StockRepository stockRepository) {
return new CheckStockExpiry(stockRepository);

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.ConfirmReservation;
import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.GetStock;
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.UpdateStock;
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.CreateStockCommand;
import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
@ -63,12 +65,14 @@ public class StockController {
private final UnblockStockBatch unblockStockBatch;
private final ReserveStock reserveStock;
private final ReleaseReservation releaseReservation;
private final ConfirmReservation confirmReservation;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
ReserveStock reserveStock, ReleaseReservation releaseReservation) {
ReserveStock reserveStock, ReleaseReservation releaseReservation,
ConfirmReservation confirmReservation) {
this.createStock = createStock;
this.updateStock = updateStock;
this.getStock = getStock;
@ -80,6 +84,7 @@ public class StockController {
this.unblockStockBatch = unblockStockBatch;
this.reserveStock = reserveStock;
this.releaseReservation = releaseReservation;
this.confirmReservation = confirmReservation;
}
@GetMapping
@ -310,6 +315,26 @@ public class StockController {
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 {
private final StockError error;