1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49:35 +01:00

feat(inventory): Inventur durchführen – Ist-Mengen erfassen (US-6.2)

Implementiert startCounting() und updateCountItem() auf dem InventoryCount-
Aggregate, zwei neue Use Cases (StartInventoryCount, RecordCountItem) mit
zugehörigen Controller-Endpoints (PATCH /{id}/start, PATCH /{id}/items/{itemId}).
Inkl. Domain-, Application-, Integrations- und Gatling-Lasttests.
This commit is contained in:
Sebastian Frick 2026-02-26 19:53:43 +01:00
parent 206921d2a6
commit 252f48d52b
17 changed files with 1451 additions and 17 deletions

View file

@ -0,0 +1,102 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.RecordCountItemCommand;
import de.effigenix.domain.inventory.CountItemId;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.InventoryCount;
import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.domain.inventory.InventoryCountId;
import de.effigenix.domain.inventory.InventoryCountRepository;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import java.math.BigDecimal;
public class RecordCountItem {
private final InventoryCountRepository inventoryCountRepository;
private final UnitOfWork unitOfWork;
private final AuthorizationPort authPort;
public RecordCountItem(InventoryCountRepository inventoryCountRepository,
UnitOfWork unitOfWork,
AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository;
this.unitOfWork = unitOfWork;
this.authPort = authPort;
}
public Result<InventoryCountError, InventoryCount> execute(RecordCountItemCommand cmd, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to record count items"));
}
if (cmd.inventoryCountId() == null || cmd.inventoryCountId().isBlank()) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank"));
}
InventoryCountId id;
try {
id = InventoryCountId.of(cmd.inventoryCountId());
} catch (IllegalArgumentException e) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage()));
}
CountItemId itemId;
try {
itemId = CountItemId.of(cmd.countItemId());
} catch (IllegalArgumentException e) {
return Result.failure(new InventoryCountError.InvalidCountItemId(e.getMessage()));
}
// Parse quantity
BigDecimal amount;
try {
amount = new BigDecimal(cmd.actualQuantityAmount());
} catch (NumberFormatException | NullPointerException e) {
return Result.failure(new InventoryCountError.InvalidQuantity(
"Invalid quantity amount: " + cmd.actualQuantityAmount()));
}
UnitOfMeasure uom;
try {
uom = UnitOfMeasure.valueOf(cmd.actualQuantityUnit());
} catch (IllegalArgumentException | NullPointerException e) {
return Result.failure(new InventoryCountError.InvalidQuantity(
"Invalid unit: " + cmd.actualQuantityUnit()));
}
Quantity actualQuantity = Quantity.reconstitute(amount, uom);
// Load aggregate
InventoryCount count;
switch (inventoryCountRepository.findById(id)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var optCount) -> {
if (optCount.isEmpty()) {
return Result.failure(new InventoryCountError.InventoryCountNotFound(cmd.inventoryCountId()));
}
count = optCount.get();
}
}
switch (count.updateCountItem(itemId, actualQuantity)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
return unitOfWork.executeAtomically(() -> {
switch (inventoryCountRepository.save(count)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(count);
});
}
}

View file

@ -0,0 +1,69 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.InventoryAction;
import de.effigenix.domain.inventory.InventoryCount;
import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.domain.inventory.InventoryCountId;
import de.effigenix.domain.inventory.InventoryCountRepository;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
public class StartInventoryCount {
private final InventoryCountRepository inventoryCountRepository;
private final UnitOfWork unitOfWork;
private final AuthorizationPort authPort;
public StartInventoryCount(InventoryCountRepository inventoryCountRepository,
UnitOfWork unitOfWork,
AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository;
this.unitOfWork = unitOfWork;
this.authPort = authPort;
}
public Result<InventoryCountError, InventoryCount> execute(String inventoryCountId, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to start inventory count"));
}
if (inventoryCountId == null || inventoryCountId.isBlank()) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank"));
}
InventoryCountId id;
try {
id = InventoryCountId.of(inventoryCountId);
} catch (IllegalArgumentException e) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage()));
}
InventoryCount count;
switch (inventoryCountRepository.findById(id)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var optCount) -> {
if (optCount.isEmpty()) {
return Result.failure(new InventoryCountError.InventoryCountNotFound(inventoryCountId));
}
count = optCount.get();
}
}
switch (count.startCounting()) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
return unitOfWork.executeAtomically(() -> {
switch (inventoryCountRepository.save(count)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(count);
});
}
}

View file

@ -0,0 +1,8 @@
package de.effigenix.application.inventory.command;
public record RecordCountItemCommand(
String inventoryCountId,
String countItemId,
String actualQuantityAmount,
String actualQuantityUnit
) {}

View file

@ -89,6 +89,10 @@ public class CountItem {
return actualQuantity.amount().subtract(expectedQuantity.amount());
}
void recordActualQuantity(Quantity actualQuantity) {
this.actualQuantity = actualQuantity;
}
public boolean isCounted() {
return actualQuantity != null;
}

View file

@ -1,8 +1,10 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
@ -21,6 +23,8 @@ import java.util.Objects;
* - Max. one active count (OPEN/COUNTING) per StorageLocation (Application Layer)
* - ArticleId unique within countItems
* - addCountItem only in status OPEN
* - startCounting only in status OPEN, requires non-empty countItems
* - updateCountItem only in status COUNTING
*/
public class InventoryCount {
@ -28,7 +32,7 @@ public class InventoryCount {
private final StorageLocationId storageLocationId;
private final LocalDate countDate;
private final String initiatedBy;
private final InventoryCountStatus status;
private InventoryCountStatus status;
private final Instant createdAt;
private final List<CountItem> countItems;
@ -110,7 +114,7 @@ public class InventoryCount {
public Result<InventoryCountError, CountItem> addCountItem(CountItemDraft draft) {
if (status != InventoryCountStatus.OPEN) {
return Result.failure(new InventoryCountError.InvalidStatusTransition(
status.name(), "addCountItem requires OPEN"));
status.name(), InventoryCountStatus.OPEN.name()));
}
CountItem item;
@ -127,6 +131,50 @@ public class InventoryCount {
return Result.success(item);
}
// ==================== Status Transitions ====================
public Result<InventoryCountError, Void> startCounting() {
if (status != InventoryCountStatus.OPEN) {
return Result.failure(new InventoryCountError.InvalidStatusTransition(
status.name(), InventoryCountStatus.COUNTING.name()));
}
if (countItems.isEmpty()) {
return Result.failure(new InventoryCountError.NoCountItems());
}
this.status = InventoryCountStatus.COUNTING;
return Result.success(null);
}
public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity) {
if (status != InventoryCountStatus.COUNTING) {
return Result.failure(new InventoryCountError.InvalidStatusTransition(
status.name(), InventoryCountStatus.COUNTING.name()));
}
var item = countItems.stream()
.filter(ci -> ci.id().equals(itemId))
.findFirst()
.orElse(null);
if (item == null) {
return Result.failure(new InventoryCountError.CountItemNotFound(itemId.value()));
}
if (actualQuantity.uom() != item.expectedQuantity().uom()) {
return Result.failure(new InventoryCountError.InvalidQuantity(
"Unit of measure mismatch: expected " + item.expectedQuantity().uom()
+ " but got " + actualQuantity.uom()));
}
if (actualQuantity.amount().compareTo(BigDecimal.ZERO) < 0) {
return Result.failure(new InventoryCountError.InvalidQuantity(
"Actual quantity must be >= 0"));
}
item.recordActualQuantity(actualQuantity);
return Result.success(null);
}
// ==================== Queries ====================
public boolean isActive() {

View file

@ -55,6 +55,11 @@ public sealed interface InventoryCountError {
@Override public String message() { return "Not all count items have been counted"; }
}
record InvalidCountItemId(String reason) implements InventoryCountError {
@Override public String code() { return "INVALID_COUNT_ITEM_ID"; }
@Override public String message() { return "Invalid count item ID: " + reason; }
}
record CountItemNotFound(String id) implements InventoryCountError {
@Override public String code() { return "COUNT_ITEM_NOT_FOUND"; }
@Override public String message() { return "Count item not found: " + id; }

View file

@ -3,6 +3,8 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts;
import de.effigenix.application.inventory.RecordCountItem;
import de.effigenix.application.inventory.StartInventoryCount;
import de.effigenix.application.inventory.GetStockMovement;
import de.effigenix.application.inventory.ListStockMovements;
import de.effigenix.application.inventory.ConfirmReservation;
@ -172,4 +174,14 @@ public class InventoryUseCaseConfiguration {
public ListInventoryCounts listInventoryCounts(InventoryCountRepository inventoryCountRepository, AuthorizationPort authorizationPort) {
return new ListInventoryCounts(inventoryCountRepository, authorizationPort);
}
@Bean
public StartInventoryCount startInventoryCount(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
return new StartInventoryCount(inventoryCountRepository, unitOfWork, authorizationPort);
}
@Bean
public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort);
}
}

View file

@ -3,10 +3,14 @@ package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts;
import de.effigenix.application.inventory.RecordCountItem;
import de.effigenix.application.inventory.StartInventoryCount;
import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.application.inventory.command.RecordCountItemCommand;
import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse;
import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -28,13 +32,19 @@ public class InventoryCountController {
private final CreateInventoryCount createInventoryCount;
private final GetInventoryCount getInventoryCount;
private final ListInventoryCounts listInventoryCounts;
private final StartInventoryCount startInventoryCount;
private final RecordCountItem recordCountItem;
public InventoryCountController(CreateInventoryCount createInventoryCount,
GetInventoryCount getInventoryCount,
ListInventoryCounts listInventoryCounts) {
ListInventoryCounts listInventoryCounts,
StartInventoryCount startInventoryCount,
RecordCountItem recordCountItem) {
this.createInventoryCount = createInventoryCount;
this.getInventoryCount = getInventoryCount;
this.listInventoryCounts = listInventoryCounts;
this.startInventoryCount = startInventoryCount;
this.recordCountItem = recordCountItem;
}
@PostMapping
@ -92,6 +102,39 @@ public class InventoryCountController {
return ResponseEntity.ok(responses);
}
@PatchMapping("/{id}/start")
@PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')")
public ResponseEntity<InventoryCountResponse> startInventoryCount(
@PathVariable String id,
Authentication authentication
) {
var result = startInventoryCount.execute(id, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue()));
}
@PatchMapping("/{id}/items/{itemId}")
@PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')")
public ResponseEntity<InventoryCountResponse> recordCountItem(
@PathVariable String id,
@PathVariable String itemId,
@Valid @RequestBody RecordCountItemRequest request,
Authentication authentication
) {
var cmd = new RecordCountItemCommand(id, itemId, request.actualQuantityAmount(), request.actualQuantityUnit());
var result = recordCountItem.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue()));
}
// ==================== Exception Wrapper ====================
public static class InventoryCountDomainErrorException extends RuntimeException {

View file

@ -0,0 +1,8 @@
package de.effigenix.infrastructure.inventory.web.dto;
import jakarta.validation.constraints.NotBlank;
public record RecordCountItemRequest(
@NotBlank String actualQuantityAmount,
@NotBlank String actualQuantityUnit
) {}

View file

@ -56,6 +56,7 @@ public final class InventoryErrorHttpStatusMapper {
public static int toHttpStatus(InventoryCountError error) {
return switch (error) {
case InventoryCountError.InventoryCountNotFound e -> 404;
case InventoryCountError.InvalidCountItemId e -> 400;
case InventoryCountError.CountItemNotFound e -> 404;
case InventoryCountError.ActiveCountExists e -> 409;
case InventoryCountError.DuplicateArticle e -> 409;