1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00

feat(inventory): Charge sperren/entsperren (blockBatch/unblockBatch) (#7)

Gesperrte Chargen können nicht entnommen oder reserviert werden.
blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; unblockBatch: BLOCKED → AVAILABLE/EXPIRING_SOON (MHD-Check).
This commit is contained in:
Sebastian Frick 2026-02-19 23:46:34 +01:00
parent 8a9d2bfc30
commit e7c3258f07
15 changed files with 934 additions and 1 deletions

View file

@ -0,0 +1,46 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.BlockStockBatchCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class BlockStockBatch {
private final StockRepository stockRepository;
public BlockStockBatch(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, Void> execute(BlockStockBatchCommand 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. Batch sperren (Domain validiert)
switch (stock.blockBatch(StockBatchId.of(cmd.batchId()))) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. Stock speichern
switch (stockRepository.save(stock)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(null);
}
}

View file

@ -0,0 +1,46 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class UnblockStockBatch {
private final StockRepository stockRepository;
public UnblockStockBatch(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, Void> execute(UnblockStockBatchCommand 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. Batch entsperren (Domain validiert)
switch (stock.unblockBatch(StockBatchId.of(cmd.batchId()))) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. Stock speichern
switch (stockRepository.save(stock)) {
case Result.Failure(var err) ->
{ return Result.failure(new StockError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(null);
}
}

View file

@ -0,0 +1,7 @@
package de.effigenix.application.inventory.command;
public record BlockStockBatchCommand(
String stockId,
String batchId,
String reason
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.application.inventory.command;
public record UnblockStockBatchCommand(
String stockId,
String batchId
) {}

View file

@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -17,6 +18,8 @@ import java.util.Objects;
* - MinimumLevel optional (quantity amount >= 0)
* - MinimumShelfLife optional (days > 0)
* - BatchReference (batchId + batchType) unique within batches
* - blockBatch: AVAILABLE/EXPIRING_SOON BLOCKED; EXPIRED not allowed; already BLOCKED error
* - unblockBatch: BLOCKED AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED error
*/
public class Stock {
@ -150,6 +153,51 @@ public class Stock {
return Result.success(null);
}
public Result<StockError, Void> blockBatch(StockBatchId batchId) {
StockBatch batch = batches.stream()
.filter(b -> b.id().equals(batchId))
.findFirst()
.orElse(null);
if (batch == null) {
return Result.failure(new StockError.BatchNotFound(batchId.value()));
}
if (batch.status() == StockBatchStatus.BLOCKED) {
return Result.failure(new StockError.BatchAlreadyBlocked(batchId.value()));
}
if (batch.status() == StockBatchStatus.EXPIRED) {
return Result.failure(new StockError.BatchNotAvailable(batchId.value(), batch.status().name()));
}
int index = this.batches.indexOf(batch);
this.batches.set(index, batch.withStatus(StockBatchStatus.BLOCKED));
return Result.success(null);
}
public Result<StockError, Void> unblockBatch(StockBatchId batchId) {
StockBatch batch = batches.stream()
.filter(b -> b.id().equals(batchId))
.findFirst()
.orElse(null);
if (batch == null) {
return Result.failure(new StockError.BatchNotFound(batchId.value()));
}
if (batch.status() != StockBatchStatus.BLOCKED) {
return Result.failure(new StockError.BatchNotBlocked(batchId.value()));
}
StockBatchStatus newStatus = StockBatchStatus.AVAILABLE;
if (minimumShelfLife != null) {
LocalDate threshold = LocalDate.now().plusDays(minimumShelfLife.days());
if (batch.expiryDate().isBefore(threshold)) {
newStatus = StockBatchStatus.EXPIRING_SOON;
}
}
int index = this.batches.indexOf(batch);
this.batches.set(index, batch.withStatus(newStatus));
return Result.success(null);
}
// ==================== Getters ====================
public StockId id() { return id; }

View file

@ -128,6 +128,10 @@ public class StockBatch {
return reconstitute(this.id, this.batchReference, newQuantity, this.expiryDate, this.status, this.receivedAt);
}
public StockBatch withStatus(StockBatchStatus newStatus) {
return reconstitute(this.id, this.batchReference, this.quantity, this.expiryDate, newStatus, this.receivedAt);
}
// ==================== Getters ====================
public StockBatchId id() { return id; }

View file

@ -70,6 +70,16 @@ public sealed interface StockError {
@Override public String message() { return "Batch " + id + " is not available for removal (status: " + status + ")"; }
}
record BatchAlreadyBlocked(String id) implements StockError {
@Override public String code() { return "BATCH_ALREADY_BLOCKED"; }
@Override public String message() { return "Batch " + id + " is already blocked"; }
}
record BatchNotBlocked(String id) implements StockError {
@Override public String code() { return "BATCH_NOT_BLOCKED"; }
@Override public String message() { return "Batch " + id + " is not blocked"; }
}
record Unauthorized(String message) implements StockError {
@Override public String code() { return "UNAUTHORIZED"; }
}

View file

@ -2,8 +2,10 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.CreateStorageLocation;
import de.effigenix.application.inventory.DeactivateStorageLocation;
import de.effigenix.application.inventory.ListStorageLocations;
@ -59,4 +61,14 @@ public class InventoryUseCaseConfiguration {
public RemoveStockBatch removeStockBatch(StockRepository stockRepository) {
return new RemoveStockBatch(stockRepository);
}
@Bean
public BlockStockBatch blockStockBatch(StockRepository stockRepository) {
return new BlockStockBatch(stockRepository);
}
@Bean
public UnblockStockBatch unblockStockBatch(StockRepository stockRepository) {
return new UnblockStockBatch(stockRepository);
}
}

View file

@ -1,13 +1,18 @@
package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CreateStock;
import de.effigenix.application.inventory.RemoveStockBatch;
import de.effigenix.application.inventory.UnblockStockBatch;
import de.effigenix.application.inventory.command.AddStockBatchCommand;
import de.effigenix.application.inventory.command.BlockStockBatchCommand;
import de.effigenix.application.inventory.command.CreateStockCommand;
import de.effigenix.application.inventory.command.RemoveStockBatchCommand;
import de.effigenix.application.inventory.command.UnblockStockBatchCommand;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.infrastructure.inventory.web.dto.AddStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.BlockStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateStockRequest;
import de.effigenix.infrastructure.inventory.web.dto.RemoveStockBatchRequest;
import de.effigenix.infrastructure.inventory.web.dto.StockBatchResponse;
@ -34,11 +39,16 @@ public class StockController {
private final CreateStock createStock;
private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch;
private final UnblockStockBatch unblockStockBatch;
public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch) {
public StockController(CreateStock createStock, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch) {
this.createStock = createStock;
this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch;
this.unblockStockBatch = unblockStockBatch;
}
@PostMapping
@ -115,6 +125,47 @@ public class StockController {
return ResponseEntity.ok().build();
}
@PostMapping("/{stockId}/batches/{batchId}/block")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<Void> blockBatch(
@PathVariable String stockId,
@PathVariable String batchId,
@Valid @RequestBody BlockStockBatchRequest request,
Authentication authentication
) {
logger.info("Blocking batch {} of stock {} by actor: {}", batchId, stockId, authentication.getName());
var cmd = new BlockStockBatchCommand(stockId, batchId, request.reason());
var result = blockStockBatch.execute(cmd);
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
logger.info("Batch {} of stock {} blocked", batchId, stockId);
return ResponseEntity.ok().build();
}
@PostMapping("/{stockId}/batches/{batchId}/unblock")
@PreAuthorize("hasAuthority('STOCK_WRITE')")
public ResponseEntity<Void> unblockBatch(
@PathVariable String stockId,
@PathVariable String batchId,
Authentication authentication
) {
logger.info("Unblocking batch {} of stock {} by actor: {}", batchId, stockId, authentication.getName());
var cmd = new UnblockStockBatchCommand(stockId, batchId);
var result = unblockStockBatch.execute(cmd);
if (result.isFailure()) {
throw new StockDomainErrorException(result.unsafeGetError());
}
logger.info("Batch {} of stock {} unblocked", batchId, stockId);
return ResponseEntity.ok().build();
}
public static class StockDomainErrorException extends RuntimeException {
private final StockError error;

View file

@ -0,0 +1,7 @@
package de.effigenix.infrastructure.inventory.web.dto;
import jakarta.validation.constraints.NotBlank;
public record BlockStockBatchRequest(
@NotBlank String reason
) {}

View file

@ -30,6 +30,8 @@ public final class InventoryErrorHttpStatusMapper {
case StockError.DuplicateBatchReference e -> 409;
case StockError.NegativeStockNotAllowed e -> 409;
case StockError.BatchNotAvailable e -> 409;
case StockError.BatchAlreadyBlocked e -> 409;
case StockError.BatchNotBlocked e -> 409;
case StockError.InvalidMinimumLevel e -> 400;
case StockError.InvalidMinimumShelfLife e -> 400;
case StockError.InvalidArticleId e -> 400;